nedots 0.1.2

A tool to manage configuration files/(ne)dots.
use std::{os::linux::fs::MetadataExt, path::Path};

#[derive(Debug, clap::Args)]
pub(crate) struct InstallCmd {
    #[arg(short, long)]
    /// Don't do anything - just show me what's going to happen.
    dry_run: bool,

    #[arg(short, long)]
    /// Ignore differences in modified time - even if a local file has been
    /// updated more recently than the remote file, overwrite it.
    force: bool,

    #[arg(short, long)]
    /// Ignore files that are present in remote, but not present locally. In
    /// other words, only _overwrite_ files - do not install any new files.
    ignore_missing: bool,

    /// Only install this source. Any unique portion of a path in `sources` is
    /// valid. E.g. given a list of [ "/home/user/.bashrc", "/home/user/.zshrc" ],
    /// ".bashrc" or ".zshrc" may be used as a key.
    key: Option<String>,

    #[command(subcommand)]
    cmd: Option<Commands>,
}

#[derive(Debug, clap::Subcommand)]
enum Commands {
    Symlink(Symlink),
}

impl super::Command for InstallCmd {
    fn run(
        &self,
        _: &super::nedots::RootCmd,
        config: &crate::config::Config,
    ) -> Result<(), super::CommandError> {
        let pb = indicatif::ProgressBar::new_spinner();
        if let Some(key) = &self.key {
            match config.get_sources_as_hashmap().get(key.as_str()) {
                Some(val) => {
                    super::backup::backup(
                        &[val.to_path_buf()],
                        &config.root.join(super::backup::get_dst()),
                        true,
                    )?;

                    let dst = Path::new(val);
                    let src = config.get_dst_from_src(dst)?;

                    pb.tick();
                    pb.set_message(format!("Installing {}...", val.to_string_lossy()));
                    install(&src, dst, self.dry_run, self.force, self.ignore_missing)?;
                }
                None => {
                    log::error!("{} not found!", key);
                }
            }
        } else {
            super::backup::backup(
                &config.sources,
                &config.root.join(super::backup::get_dst()),
                true,
            )?;

            for source in &config.sources {
                // Joining absolute paths doesn't work - `dots_dir` is replaced with
                // `src`. We have to strip the prefix, resulting in a relative path
                // being joined instead.
                let dst = Path::new(source);
                let src = config.get_dst_from_src(dst)?;

                pb.tick();
                pb.set_message(format!("Installing {}...", source.to_string_lossy()));
                install(&src, dst, self.dry_run, self.force, self.ignore_missing)?;
            }
        }

        pb.finish_and_clear();
        Ok(())
    }
}

fn install(
    src: &Path,
    dst: &Path,
    dry_run: bool,
    force: bool,
    ignore_missing: bool,
) -> Result<(), super::CommandError> {
    log::debug!(
        "Installing {} -> {}",
        src.file_name().unwrap().to_string_lossy(),
        dst.to_string_lossy()
    );

    if src.is_dir() {
        for entry in src.read_dir()? {
            let path = entry?.path();
            install(
                &path,
                &dst.join(&path.file_name().unwrap()),
                dry_run,
                force,
                ignore_missing,
            )?;
        }
    } else {
        let src_metadata: std::fs::Metadata;
        let src_modified: std::time::SystemTime;
        match src.metadata() {
            Ok(res) => {
                src_modified = res.modified()?;
                src_metadata = res;
            }
            Err(err) => {
                log::error!(
                    "{} does not exist - check your config for old entries",
                    src.to_string_lossy().to_string()
                );
                return Err(crate::cmd::CommandError::IoError(err));
            }
        };

        let mut dst_metadata: Option<std::fs::Metadata> = None;
        let dst_modified: std::time::SystemTime;
        match dst.metadata() {
            Ok(res) => {
                dst_modified = res.modified()?;
                dst_metadata = Some(res);
            }
            Err(_) => match ignore_missing {
                true => dst_modified = std::time::SystemTime::now(),
                false => dst_modified = std::time::SystemTime::UNIX_EPOCH,
            },
        };

        if !force && dst_modified.gt(&src_modified) {
            log::error!(
                "Desination modified sooner than source, {} seconds difference ({})",
                (src_modified.elapsed()? - dst_modified.elapsed()?).as_secs(),
                dst.to_string_lossy().to_string()
            );
            return Ok(());
        }

        if let Some(dst_metadata) = dst_metadata {
            if dst_metadata.st_uid().ne(&src_metadata.st_uid()) {
                log::warn!(
                    "Insufficient permissions ({})",
                    dst.to_string_lossy().to_string(),
                );
                return Ok(());
            }

            if dst_metadata.len().ne(&src_metadata.len()) {
                log::info!(
                    "{} bytes difference ({})",
                    dst_metadata.len().abs_diff(src_metadata.len()),
                    dst.to_string_lossy().to_string()
                );
            }
        }

        if dry_run {
            return Ok(());
        } else {
            super::gather::gather(src, dst)?;
        }
    }

    Ok(())
}

#[derive(Debug, clap::Args)]
struct Symlink;

impl super::Command for Symlink {
    fn run(
        &self,
        _: &super::nedots::RootCmd,
        _: &crate::config::Config,
    ) -> Result<(), super::CommandError> {
        Ok(())
    }
}