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()
}
fn get_config_path(mut parent_dir: PathBuf) -> PathBuf {
parent_dir.push(CONFIG_FILE_NAME);
parent_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())
}
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
}
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, ©_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(())
}
}