use crossterm::style::Stylize; use semver::Version; use std::{ collections::HashMap, fs, path::{Path, PathBuf}, }; use toml::{ value::{Map, Table}, Value, }; pub mod cli; mod colors; mod error; mod project; mod systems; use colors::*; pub use error::*; pub use project::*; pub struct Vcs { work_dir: PathBuf, profiles: Vec, projects: Vec, } impl Vcs { pub fn new(work_dir: PathBuf) -> Result { // find Cargo.toml recursively from given path let toml_path = Self::search(&work_dir)?; // set work_dir to Cargo.toml parent folder let work_dir = toml_path .parent() .ok_or_else(|| { Error::input_error( "Invalid work_dir found", toml_path.to_str().expect("Invalid UTF-8 in path"), ) })? .to_path_buf(); if !toml_path.exists() { return Err(Error::input_error( "work_dir invalid", &work_dir.display().to_string(), )); } if !toml_path.exists() { return Err(Error::input_error( "Unable to find Cargo.toml", &toml_path.display().to_string(), )); } let workspace = Self::get_workspace_toml(&toml_path)?; let members = workspace .get("members") .ok_or_else(|| Error::cargo_error("Cargo.toml missing [members] array"))? .as_array() .ok_or_else(|| Error::cargo_error("Cargo.toml [members] is not an array"))? .to_owned(); let vcs_path = work_dir.join("Cargo_vcs.toml"); let profiles_map = if vcs_path.exists() { let vcs_contents = fs::read_to_string(vcs_path)?; let vcs: toml::Value = toml::from_str(&vcs_contents)?; let vcs_main = vcs .as_table() .ok_or_else(|| Error::cargo_error("Cargo_vcs.toml is not a yaml table"))? .to_owned(); Self::find_profiles(&vcs_main)? } else { HashMap::new() }; let profiles = profiles_map.keys().cloned().collect(); let projects = Self::process_repos(&work_dir, &members, &profiles_map)?; Ok(Self { work_dir, profiles, projects, }) } pub fn list(&self) { for project in self.projects() { println!("{}", project); } } pub fn status(&self) -> Result<(), Error> { if let Some(profile_name) = self.current_profile()? { println!( "Workspace is set to profile: {} [{}] {}", profile_name.bold().with(PROFILE_COLOR), format!("{}", self.work_dir.display()).italic(), self.min_msrv_str(), ); let mut once = true; for project in &self.projects { if once { println!("\n{}\n", "** Projects with uncommitted changes **".italic()); once = false; } if !project.repo.uncommitted_changes()?.is_empty() { println!( "{}", project.display(ProjectDisplayOptions::Changes.into())? ); } } } else { if self.profiles.is_empty() { println!( "{}", "No profiles defined, use 'cargo vcs save' to generate one\n".with(ERROR_COLOR) ); } else { println!( "{}", "Mismatching projects and profiles\n".with(ERROR_COLOR) ); } self.list(); } Ok(()) } pub fn projects(&self) -> Projects<'_> { self.projects.iter() } fn min_msrv_str(&self) -> String { if let Some(msrv) = self.min_msrv() { format!("MSRV: {}", msrv).with(MSRV_COLOR).to_string() } else { String::new() } } fn min_msrv(&self) -> Option<&Version> { let mut min_msrv = None; for project in &self.projects { let msrv = project.msrv(); if min_msrv.is_none() || (msrv.is_some() && msrv < min_msrv) { min_msrv = msrv; } } min_msrv } pub fn list_profiles(&self) -> Result<(), Error> { let current_profile = self.current_profile()?; let mut prefix = ""; print!("["); for profile in &self.profiles { if current_profile.as_ref() == Some(profile) { print!("{}{}", prefix, profile.as_str().bold().with(PROFILE_COLOR)); } else { print!("{}{}", prefix, profile.as_str().with(PROFILE_COLOR)); } prefix = ", "; } println!("]"); Ok(()) } pub fn save_profile(&mut self, profile_name: &str) -> Result<(), Error> { let vcs_path = self.work_dir.join("Cargo_vcs.toml"); let vcs = match fs::read_to_string(&vcs_path) { Ok(val) => toml::from_str(&val)?, Err(_) => Value::Table(Map::new()), }; let mut vcs_main = vcs .as_table() .ok_or_else(|| Error::cargo_error("Cargo_vcs.toml is not a yaml table"))? .to_owned(); let value = self.current_profile_toml_value()?; if let Some(vcs_section) = vcs_main.get_mut("vcs") { if let Some(section) = vcs_section.as_table_mut() { section.insert(profile_name.into(), value); } else { return Err(Error::Upstream(Box::new(toml::ser::Error::Custom( "Existing vcs section not a table".into(), )))); } } else { let mut section = Table::new(); section.insert(profile_name.into(), value); vcs_main.insert("vcs".into(), Value::Table(section)); } let new_contents = toml::to_string(&vcs_main)?; fs::write(vcs_path, new_contents)?; if !&self.profiles.iter().any(|p| p == profile_name) { self.profiles.push(profile_name.into()); } println!( "Workspace state saved as {}", profile_name.with(PROFILE_COLOR), ); Ok(()) } pub fn set_profile(&mut self, profile_name: &str) -> Result<(), Error> { if !self.profiles.contains(&String::from(profile_name)) { eprintln!( "{}{}", "Profile not found: ".with(ERROR_COLOR), profile_name.with(PROFILE_COLOR) ); return Ok(()); } self.set_projects_using(profile_name, |project| { project.switch_to_profile(profile_name) }) } pub fn set_branch(&mut self, branch_name: &str) -> Result<(), Error> { self.set_projects_using(branch_name, |project| project.repo.checkout(branch_name)) } fn set_projects_using(&mut self, dest_name: &str, setter: F) -> Result<(), Error> where F: Fn(&mut Project) -> Result, { for project in &mut self.projects { let current_ref = project.repo.current_ref()?; // stash changes before moving project off to a new ref let stashed_changes = project.repo.stash_changes(¤t_ref)?; // try to switch to new ref using provided setter let new_ref = match setter(project) { Ok(val) => val, Err(err) => { eprintln!( "{} unable to set {} ({})", project.name().bold().with(PROJECT_COLOR), dest_name.with(REFS_COLOR), err.to_string().with(ERROR_COLOR), ); continue; } }; // unstash changes if we had previously stashed any on this ref let unstashed_changes = project.repo.unstash_changes(&new_ref)?; println!( "{} set to {} {}{}", project.name().bold().with(PROJECT_COLOR), new_ref.with(REFS_COLOR), if stashed_changes { "«".cyan() } else { "".bold() }, if unstashed_changes { "»".dark_blue() } else { "".bold() }, ); } Ok(()) } fn current_profile_toml_value(&self) -> Result { let mut table = Table::new(); for project in &self.projects { let head_ref_name = project.repo.current_ref()?; table.insert(project.name().into(), Value::String(head_ref_name)); } Ok(Value::Table(table)) } fn current_profile(&self) -> Result, Error> { let mut found_profile = None; for project in &self.projects { match project.current_profile()? { None => return Ok(None), // gap Some(project_profile) => match &mut found_profile { Some(previous) => { if previous != &project_profile { return Ok(None); // mismatch } } None => { found_profile.replace(project_profile); } }, } } Ok(found_profile) } fn find_profiles( vcs_main: &Map, ) -> Result>, Error> { let mut result = HashMap::new(); if let Some(vcs_section) = vcs_main.get("vcs") { if let Some(vcs) = vcs_section.as_table() { for profile in vcs { if let Some(repo_to_branch) = profile.1.as_table() { let mut names = HashMap::new(); for rtb in repo_to_branch { let project_name = rtb.0.clone(); let branch_name = rtb.1.as_str().ok_or_else(|| { Error::project_error("Invalid UTF-8 in branch name") })?; names.insert(project_name, branch_name.to_owned()); } let profile_name = profile.0.clone(); result.insert(profile_name, names); } } } } Ok(result) } fn process_repos( work_dir: &Path, members: &Vec, profiles: &HashMap>, ) -> Result, Error> { let mut projects = Vec::new(); for member in members { let repo_postfix = member .as_str() .ok_or_else(|| Error::project_error("Repo not a string"))?; let repo_path = work_dir.join(repo_postfix); let repo_basename = Path::new(repo_postfix) .components() .last() .ok_or_else(|| Error::project_error("Repo basename not found"))? .as_os_str() .to_str() .ok_or_else(|| Error::project_error("Repo basename not valid UTF-8"))?; let mut profile_map = HashMap::new(); for profile in profiles { if let Some(branch_name) = profile.1.get(repo_basename) { profile_map.insert(profile.0.clone(), branch_name.clone()); } } projects.push(Project::new(repo_path, profile_map)?); } Ok(projects) } fn search(path: &Path) -> Result { let path_str = path.to_str().expect("Invalid UTF-8 in path"); if !path.exists() { return Err(Error::input_error("Search path not found", path_str)); } if !path.is_dir() { return Err(Error::input_error("Search path not a directory", path_str)); } let mut path = path; loop { let try_cargo = path.join("Cargo.toml"); if try_cargo.exists() && Self::get_workspace_toml(&try_cargo).is_ok() { return Ok(try_cargo); } else { path = path .parent() .ok_or_else(|| Error::input_error("No workspace found", path_str))?; } } } fn get_workspace_toml(path: &Path) -> Result, Error> { let toml_contents = fs::read_to_string(path)?; let cargo: toml::Value = toml::from_str(&toml_contents)?; let cargo_main = cargo .as_table() .ok_or_else(|| Error::cargo_error("Cargo.toml is not a yaml table"))? .to_owned(); let workspace = cargo_main .get("workspace") .ok_or_else(|| Error::cargo_error("Cargo.toml is not a workspace"))? .as_table() .ok_or_else(|| Error::cargo_error("Cargo.toml [workspace] is not a table"))? .to_owned(); Ok(workspace) } }