shellock-homes 0.1.0

opinonated dotfile manager
Documentation
use anyhow::Result;
use log::debug;
use log::error;
use log::warn;
use path_helpers::{config_file_name, strip};
use serde::{Deserialize, Serialize};
use std::{
    fs::{self, File},
    io::Write,
    path::PathBuf,
};
use thiserror::Error;
use walkdir::WalkDir;

mod constants;
mod path_helpers;

use constants::{CONFIG_FILE_NAME, SHELLOCK_HOMES};
use path_helpers::{data_dir, flip_path, home_dir};

#[derive(Debug, Error)]
pub enum Error {
    #[error("io error {e:?}")]
    IO { e: std::io::Error },
    #[error("directory error")]
    Dir,
    #[error("Could not read config file")]
    Read,
    #[error("Could not write config file")]
    Write,
    #[error("serialization error {e:?}")]
    Serde { e: serde_json::Error },
}

pub enum Direction {
    FromHome,
    FromRepo,
}

pub trait Backend: Default {
    fn init(&self) -> Result<()>;
    fn sync(&self, direction: Direction, files: Vec<PathBuf>, ignored: Vec<PathBuf>) -> Result<()>;
    fn list(&self) -> Vec<PathBuf>;
}

#[derive(Debug, Deserialize, Serialize)]
pub struct SyncOptions {
    pub files: Vec<PathBuf>,
    pub ignore: Vec<PathBuf>,
}

impl SyncOptions {
    fn default() -> Self {
        let mut ignore: Vec<PathBuf> = Vec::new();
        let p = PathBuf::from(".git");
        ignore.push(p);
        let mut files: Vec<PathBuf> = Vec::new();
        let cfg = strip(config_file_name());
        files.push(cfg);
        SyncOptions { files, ignore }
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct FileSystemBackend {
    pub config_dir: PathBuf,
    pub data_dir: PathBuf,
}

impl Default for FileSystemBackend {
    fn default() -> Self {
        // TODO implement for non-xdg platforms
        let xdg_dirs = xdg::BaseDirectories::with_prefix(SHELLOCK_HOMES).unwrap();
        // ensure directories exists
        fs::create_dir_all(xdg_dirs.get_config_home()).unwrap();
        fs::create_dir_all(xdg_dirs.get_data_home()).unwrap();

        return FileSystemBackend {
            config_dir: xdg_dirs.get_config_home(),
            data_dir: xdg_dirs.get_data_home(),
        };
    }
}

impl Backend for FileSystemBackend {
    fn init(&self) -> Result<()> {
        debug!("init data dir: {:?}", data_dir());
        fs::create_dir_all(data_dir())?;
        Ok(())
    }

    fn sync(&self, direction: Direction, files: Vec<PathBuf>, ignored: Vec<PathBuf>) -> Result<()> {
        match direction {
            Direction::FromHome => sync(home_dir(), files, ignored),
            Direction::FromRepo => sync(data_dir(), files, ignored),
        }
    }

    fn list(&self) -> Vec<PathBuf> {
        let mut files = Vec::new();
        for f in WalkDir::new(data_dir()).into_iter().filter_map(|f| f.ok()) {
            files.push(f.clone().into_path());
        }

        files
    }
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Config<T: Backend> {
    pub backend: T,
    pub sync: SyncOptions,
}

pub trait ConfigWithBackend {
    fn save(&self) -> Result<()>;
    fn load(&mut self) -> Result<()>;
    fn add_path(&mut self, source_path: Vec<PathBuf>) -> Result<()>;
    fn remove_path(&mut self, backend_path: Vec<PathBuf>) -> Result<()>;
    fn init(&self) -> Result<()>;
}

impl Config<FileSystemBackend> {
    pub fn config_path(&self) -> PathBuf {
        self.backend.config_dir.clone().join(CONFIG_FILE_NAME)
    }

    pub fn default() -> Self {
        let backend = FileSystemBackend::default();
        Config {
            backend,
            sync: SyncOptions::default(),
        }
    }

    pub fn exists(&self) -> bool {
        debug!("config path {:?}", self.config_path());
        return self.config_path().as_path().exists();
    }
}

impl ConfigWithBackend for Config<FileSystemBackend> {
    fn save(&self) -> Result<()> {
        let configs = vec![config_file_name(), flip_path(config_file_name())?];
        for config_file in configs {
            if !config_file.exists() {
                let mut dir = config_file.clone();
                dir.pop();
                fs::create_dir_all(dir).unwrap();
                File::create(&config_file).unwrap();
            }
            let content = serde_json::to_string_pretty(self)?;
            let res = File::options()
                .write(true)
                .open(config_file)
                .or(Err(Error::Write));

            match res {
                Ok(mut fh) => fh
                    .write_all(content.as_bytes())
                    .or_else(|e| Err(Error::IO { e }))?,
                Err(e) => return Err(e.into()),
            };
        }
        Ok(())
    }

    fn load(&mut self) -> Result<()> {
        fs::read(self.config_path()).and_then(|bytes| {
            let s = String::from_utf8(bytes).unwrap();
            debug!("content: {}", s);
            *self = serde_json::from_str(s.as_str()).unwrap();
            Ok(())
        })?;
        Ok(())
    }

    fn add_path(&mut self, source_path: Vec<PathBuf>) -> Result<()> {
        self.sync.files.extend(source_path);
        self.save()
    }

    fn remove_path(&mut self, backend_path: Vec<PathBuf>) -> Result<()> {
        self.sync.files.retain(|f| !backend_path.contains(f));
        self.save()
    }

    fn init(&self) -> Result<()> {
        debug!("initializing backend");
        self.backend.init()?;
        debug!("backend initialized");
        if !self.config_path().as_path().exists() {
            debug!("saving config");
            return self.save();
        }
        debug!("config exists");
        Ok(())
    }
}

fn sync(source: PathBuf, files: Vec<PathBuf>, ignore: Vec<PathBuf>) -> Result<()> {
    for file in files {
        let source = source.join(file);
        debug!("source: {:?}", source);
        if source.is_file() {
            let dest = flip_path(source.clone())?;
            let base = dest.parent();
            if base.is_some() {
                fs::create_dir_all(base.unwrap())?;
            }
            fs::copy(source, dest)?;
            continue;
        }
        debug!("walking");
        for f in walkdir::WalkDir::new(&source).into_iter().filter(|f| {
            debug!("filtering {:?}", f);
            for p in &ignore {
                debug!("checking {:?}", p);
                match f.as_ref() {
                    Ok(r) => {
                        if r.path().starts_with(home_dir().join(p)) {
                            debug!("ignoring {:?}", p);
                            return false;
                        }
                    }
                    Err(e) => {
                        warn!("ignoring {:?} due to {:?}", p, e);
                        return false;
                    }
                }
            }
            true
        }) {
            let entry = f.unwrap();
            debug!("{:?} reached block", entry);
            if entry.path().is_dir() {
                debug!("{:?} is dir", entry);
                let d = flip_path(entry.into_path()).unwrap();
                debug!("dest: {:?}", d);
                fs::create_dir_all(d).unwrap();
            } else if entry.path().is_file() {
                debug!("{:?} is file", entry);
                let s = entry.clone().into_path();
                let d = flip_path(entry.into_path()).unwrap();
                debug!("source: {:?} dest: {:?}", s, d);
                fs::copy(s, d).unwrap();
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use serial_test::serial;
    use std::env;

    const TEST_DATA_DIR: &str = "testdata";

    #[test]
    #[serial]
    fn test_sync() {
        env_logger::init();

        let test_data = PathBuf::from(TEST_DATA_DIR).join(PathBuf::from("dir"));
        let test_home = tempfile::tempdir().unwrap().into_path();
        let files = vec![
            PathBuf::from("a-file.txt"),
            PathBuf::from("layer1/layer2/another-file.txt"),
        ];
        let ignore = vec![
            PathBuf::from("ignored"),
            PathBuf::from("layer1/ignore-me.txt"),
        ];
        env::set_var("HOME", test_home.clone().as_os_str());
        env::set_var(
            "XDG_DATA_HOME",
            env::current_dir().unwrap().join(test_data.clone()),
        );
        debug!(
            "HOME: {:?} XDG_DATA_HOME: {:?}",
            env::var("HOME").unwrap(),
            env::var("XDG_DATA_HOME").unwrap(),
        );
        debug!("home: {:?} data: {:?}", home_dir(), data_dir());
        let res = sync(data_dir(), files.clone(), ignore.clone());
        assert!(res.is_ok());

        for elem in files {
            let f = home_dir().join(elem);
            assert!(f.exists());
            assert!(f.is_file());
        }

        for i in ignore {
            let ignored = home_dir().join(i);
            assert!(!ignored.exists());
        }
    }
}