plimeor_dotfiles 0.1.0

Pesonal dotfiles manager
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{env, fs, io, os};

const CONFIG_FILE_NAME: &str = "dotfiles.config.json";
const DOTFILES_ENV_NAME: &str = "DOTFILES";

fn expand_home_dir(path_like: &str) -> String {
    if path_like.starts_with("~/") {
        let home_dir = env::var("HOME").expect("Can not find $HOME env");
        path_like.replacen('~', home_dir.as_str(), 1)
    } else {
        path_like.to_string()
    }
}

fn ensure_parent_dir(target: &Path) -> io::Result<()> {
    if let Some(parent_dir) = target.parent() {
        fs::create_dir_all(parent_dir)
    } else {
        Ok(())
    }
}

fn symlink(src: &PathBuf, dest: &PathBuf) -> io::Result<()> {
    os::unix::fs::symlink(src, dest)
}

type Link = (PathBuf, PathBuf);

#[derive(Debug)]
pub struct Dotfiles {
    links: Vec<Link>,
}

impl Dotfiles {
    fn root_dir() -> Option<PathBuf> {
        env::var(DOTFILES_ENV_NAME).map(PathBuf::from).ok()
    }

    // get config file path by parent dir
    fn get_config_path(mut parent_dir: PathBuf) -> PathBuf {
        parent_dir.push(CONFIG_FILE_NAME);
        parent_dir
    }

    // get config file path by root dir or current dir
    fn get_config_file() -> Option<PathBuf> {
        Dotfiles::root_dir()
            .or_else(|| env::current_dir().ok())
            .map(Dotfiles::get_config_path)
            .filter(|path| path.exists())
    }

    // create config file if not exists
    fn create_config_file_if_not_exists(config_path: PathBuf) -> PathBuf {
        if !config_path.exists() {
            fs::write(&config_path, "{}")
                .unwrap_or_else(|_| panic!("Failed writing {config_path:?}"));
        }
        config_path
    }

    /// read config file, return None if not exists
    fn read_config_file(config_path: PathBuf) -> Dotfiles {
        if !config_path.exists() {
            panic!("Config file {config_path:?} not exists");
        }

        let config: HashMap<String, HashMap<String, String>> = fs::read_to_string(&config_path)
            .ok()
            .and_then(|content| serde_json::from_str(&content).ok())
            .unwrap_or_else(|| panic!("Failed to parse config file {config_path:?}"));

        let mut links: Vec<Link> = vec![];
        let parent_dir = config_path.parent().expect("Failed to get parent dir");

        config.iter().for_each(|(scope, map)| {
            let prefix = PathBuf::from(parent_dir).join(scope);

            map.iter().for_each(|(to, from)| {
                let from = PathBuf::from(expand_home_dir(from));
                let to = prefix.join(to);
                links.push((to, from));
            })
        });

        Dotfiles::check_health(&links);

        Dotfiles { links }
    }

    fn check_health(links: &[Link]) {
        let mut links: Vec<String> = links
            .iter()
            .flat_map(|link| vec![link.0.clone(), link.1.clone()])
            .map(|link| link.to_str().unwrap().to_string())
            .collect();

        links.sort_by_key(|a| a.len());

        for i in 0..links.len() {
            for j in i + 1..links.len() {
                if links[j].starts_with(&links[i]) {
                    panic!("Conflict files: {} and {}", &links[i], &links[j])
                }
            }
        }
    }

    pub fn new() -> Self {
        env::current_dir()
            .ok()
            .map(Dotfiles::get_config_path)
            .map(Dotfiles::create_config_file_if_not_exists)
            .map(Dotfiles::read_config_file)
            .expect("Failed to create config")
    }

    pub fn read_config() -> Dotfiles {
        Dotfiles::get_config_file()
            .map(Dotfiles::read_config_file)
            .expect("Failed to read config")
    }

    pub fn collect() -> Result<(), Box<dyn std::error::Error>> {
        let config = Dotfiles::read_config();

        for (dest, src) in config.links.iter() {
            println!("Collecting {:?}", src);

            if src.is_symlink() {
                let target = &fs::read_link(src)?;
                if target == dest {
                    continue;
                } else {
                    panic!(
                        "Destination {:?} is link to {:?}, instead {:?}",
                        src, target, dest
                    );
                }
            } else if src.exists() {
                let copy_options = fs_extra::dir::CopyOptions::new();
                fs_extra::copy_items(&[src], dest, &copy_options)?;
                fs_extra::remove_items(&[src])?;
            }
            ensure_parent_dir(src)?;
            symlink(dest, src)?;
        }
        Ok(())
    }

    pub fn restore() -> Result<(), Box<dyn std::error::Error>> {
        let config = Dotfiles::read_config();
        for (dest, src) in config.links.iter() {
            if dest.exists() {
                println!("Restoring {:?}", dest);
                fs_extra::remove_items(&[src])?;
                fs_extra::copy_items(&[dest], src, &fs_extra::dir::CopyOptions::new())?;
            }
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::error::Error;

    #[cfg(unix)]
    #[test]
    fn test_expand_home_dir() -> Result<(), Box<dyn Error>> {
        let origin_home_env = env::var("HOME")?;
        let test_home_env = "dotfiles_test_user";
        let test_path = "~/.zshrc";

        env::set_var("HOME", test_home_env);
        let expanded_path = expand_home_dir(test_path);
        env::set_var("HOME", origin_home_env);

        assert_eq!(
            expanded_path,
            format!("{}/.zshrc", test_home_env),
            "expand_home_dir failed"
        );
        assert_eq!("./zshrc", expand_home_dir("./zshrc"));
        Ok(())
    }

    #[test]
    fn test_ensure_parent_dir() -> io::Result<()> {
        let parent_path = tempfile::tempdir()?.into_path();
        ensure_parent_dir(&parent_path.join("a/b/c/d"))?;
        assert!(parent_path.join("a/b/c").exists());
        assert!(!parent_path.join("a/b/c/d").exists());
        ensure_parent_dir(&PathBuf::from(""))?;
        Ok(())
    }
}