nedots 0.1.3

A tool to manage configuration files/(ne)dots.
use crate::paths::Metadata;
use anyhow::Context;
use std::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 {
    /// Create symlinks in `root` directory (default $HOME/.nedots). This is
    /// a little helper tool to quickly enable you to edit all your dots from
    /// a single directory through symlinks.
    Symlink(Symlink),
}

impl super::Command for InstallCmd {
    fn run(
        &self,
        _: &super::nedots::RootCmd,
        config: &crate::config::Config,
    ) -> anyhow::Result<()> {
        if let Some(key) = &self.key {
            match config.get_sources_as_hashmap().get(key.as_str()) {
                Some(dst) => {
                    super::backup::backup(
                        &[dst.to_path_buf()],
                        &config
                            .root
                            .join(&config.backup_dir)
                            .join(super::backup::get_timestamp()),
                        true,
                    )?;

                    install(
                        &config.get_dst_from_src(dst)?,
                        dst,
                        self.dry_run,
                        self.force,
                        self.ignore_missing,
                    )?;
                }
                None => {
                    log::error!("❌ {} not found!", key);
                }
            }
        } else {
            super::backup::backup(
                &config.sources,
                &config
                    .root
                    .join(config.backup_dir.join(super::backup::get_timestamp())),
                true,
            )?;

            for dst 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.
                install(
                    &config.get_dst_from_src(dst)?,
                    dst,
                    self.dry_run,
                    self.force,
                    self.ignore_missing,
                )?
            }
        }

        Ok(())
    }
}

fn install(
    src: &Path,
    dst: &Path,
    dry_run: bool,
    force: bool,
    ignore_missing: bool,
) -> anyhow::Result<()> {
    install_file(src, dst, dry_run, force, ignore_missing)?;
    log::info!(
        "👍 Installed {} -> {}",
        console::style(src.display()).green().bold(),
        console::style(dst.display()).green().bold()
    );
    Ok(())
}

fn install_file(
    src: &Path,
    dst: &Path,
    dry_run: bool,
    force: bool,
    ignore_missing: bool,
) -> anyhow::Result<()> {
    log::trace!(
        "Installing {} -> {}",
        src.file_name().unwrap().to_string_lossy(),
        dst.display()
    );

    if src.is_dir() {
        for entry in src.read_dir()? {
            let path = entry?.path();
            install_file(
                &path,
                &dst.join(path.file_name().unwrap()),
                dry_run,
                force,
                ignore_missing,
            )?;
        }
    } else {
        let src_metadata = src
            .get_metadata()
            .with_context(|| "Source does not exist - `gather` first!".to_string())?;
        let src_modified = src.get_modified()?;

        let dst_modified: std::time::SystemTime;
        let dst_metadata = match dst.get_metadata() {
            Ok(metadata) => {
                dst_modified = dst.get_modified()?;
                Some(metadata)
            }
            Err(err) => {
                match ignore_missing {
                    true => dst_modified = std::time::SystemTime::now(),
                    false => {
                        dst_modified = std::time::SystemTime::UNIX_EPOCH;
                        log::error!("{}", err.to_string());
                    }
                };
                None
            }
        };

        if !force && dst_modified.gt(&src_modified) {
            log::error!(
                "{}, {} seconds difference",
                console::style("❌ Destination is newer than source")
                    .red()
                    .bold(),
                (src_modified.elapsed()? - dst_modified.elapsed()?).as_secs(),
            );
            log::warn!(
                "Use `{}` (with caution) to overwrite",
                console::style("-f/--force").yellow().bold()
            );
            log::warn!(
                "Use `{}` to only install this file/directory ",
                console::style(format!(
                    "nedots install {}",
                    src.file_name().unwrap().to_string_lossy()
                ))
                .yellow()
                .bold()
            );
            log::warn!(
                "The value must be part of the path defined in `{}`, and {}",
                console::style("sources").yellow().italic(),
                console::style("unique").yellow().bold()
            );
            return Ok(());
        }

        if let Some(dst_metadata) = dst_metadata {
            #[cfg(unix)]
            {
                use std::os::linux::fs::MetadataExt;

                if dst_metadata.st_uid().ne(&src_metadata.st_uid()) {
                    log::warn!("Insufficient permissions ({})", dst.display());
                    return Ok(());
                }
            }

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

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

    Ok(())
}

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

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