cargo-vcs 0.1.0

Cargo workspace helper for Version Control System project management
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<String>,
    projects: Vec<Project>,
}

impl Vcs {
    pub fn new(work_dir: PathBuf) -> Result<Self, Error> {
        // 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(),
            );
        } 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 save_profile(&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)?;

        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<F>(&mut self, dest_name: &str, setter: F) -> Result<(), Error>
    where
        F: Fn(&mut Project) -> Result<String, Error>,
    {
        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(&current_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<Value, Error> {
        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<Option<String>, 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<String, Value>,
    ) -> Result<HashMap<String, HashMap<String, String>>, 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<Value>,
        profiles: &HashMap<String, HashMap<String, String>>,
    ) -> Result<Vec<Project>, 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<PathBuf, Error> {
        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<Map<String, Value>, 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)
    }
}