zenops 0.6.0

Declarative system configuration management for shell config and dotfiles.
Documentation
use similar::{ChangeTag, TextDiff};
use smol_str::SmolStr;
use std::{
    fmt,
    path::{Path, PathBuf},
    sync::Arc,
};

use crate::{
    config_files::{ConfigFileDirs, ConfigFilePath},
    git::GitFileStatus,
};

#[derive(Debug, PartialEq, Clone, Eq, PartialOrd, Ord)]
pub enum SymlinkStatus {
    Ok,
    WrongLink(PathBuf),
    /// The symlink does not exist and must be created
    New,
    /// The path is a file and not a symlink
    IsFile,
    /// The path is a directory and not a symlink
    IsDir,
    /// The symlink exists and points to the correct location, but the source does not exist.
    RealPathIsMissing,
    /// The directory that should contain the symlink is missing
    DstDirIsMissing,
}

#[derive(Debug, PartialEq, Clone, Eq, PartialOrd, Ord)]
pub enum FileStatus {
    Ok,
    Modified,
    New,
}

#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfigFilePath {
    pub path: ConfigFilePath,
    pub full: Arc<Path>,
}

impl ResolvedConfigFilePath {
    pub fn resolve(path: ConfigFilePath, dirs: &ConfigFileDirs) -> Self {
        let full = Arc::from(path.resolved(dirs));
        Self { path, full }
    }

    pub fn parent(&self) -> Option<Self> {
        Some(Self {
            path: self.path.parent()?,
            full: self.full.parent().map(Arc::from)?,
        })
    }
}

impl fmt::Display for ResolvedConfigFilePath {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(&self.path.human_path(), f)
    }
}

#[derive(Debug, PartialEq, Clone)]
pub enum Status {
    Generated {
        want_content: Arc<str>,
        cur_content: Option<String>,
        path: ResolvedConfigFilePath,
        status: FileStatus,
    },
    Symlink {
        real: ResolvedConfigFilePath,
        symlink: ResolvedConfigFilePath,
        status: SymlinkStatus,
    },
    Git {
        repo: ResolvedConfigFilePath,
        status: GitFileStatus,
    },
    /// A pkg the user expects to be present (`enable = "on"`) whose detect
    /// strategies don't match on the current host. `install_command` is the
    /// ready-to-run shell line (`"brew install python"`) when a package
    /// manager with a non-empty hint is detected, `None` otherwise.
    PkgMissing {
        pkg: SmolStr,
        install_command: Option<String>,
    },
}

#[derive(Debug, PartialEq)]
pub enum AppliedAction {
    UpdatedFile(ResolvedConfigFilePath),
    CreatedFile(ResolvedConfigFilePath),
    CreatedSymlink {
        real: ResolvedConfigFilePath,
        symlink: ResolvedConfigFilePath,
    },
    CreatedDir(ResolvedConfigFilePath),
}

pub trait Output {
    fn push_status(&mut self, status: Status);
    fn push_applied_action(&mut self, action: AppliedAction);
}

pub struct Log;

impl Output for Log {
    fn push_status(&mut self, status: Status) {
        match status {
            Status::Generated {
                want_content: _,
                cur_content: _,
                path,
                status,
            } => match status {
                FileStatus::Ok => log::debug!("GEN: {path} is unchanged"),
                FileStatus::Modified => log::info!("GEN: {path} is modified"),
                FileStatus::New => log::info!("GEN: {path} is missing"),
            },
            Status::Symlink {
                real,
                symlink,
                status,
            } => match status {
                SymlinkStatus::Ok => log::debug!("SYM: {symlink} is unchanged"),
                SymlinkStatus::WrongLink(path) => {
                    log::info!("SYM: {symlink} does not point to {real}, but instead {path:?}")
                }
                SymlinkStatus::New => log::info!("SYM: {symlink} is missing"),
                SymlinkStatus::IsFile => log::warn!("SYM: {symlink} is a file"),
                SymlinkStatus::IsDir => log::warn!("SYM: {symlink} is a directory"),
                SymlinkStatus::RealPathIsMissing => {
                    log::info!("SYM: symlink source {real} is missing")
                }
                SymlinkStatus::DstDirIsMissing => log::info!("SYM: {symlink} directory is missing"),
            },
            Status::Git { repo, status } => match status {
                GitFileStatus::Modified(path) => log::info!("GIT: {repo}/{path} is modified"),
                GitFileStatus::Added(path) => log::info!("GIT: {repo}/{path} is added"),
                GitFileStatus::Deleted(path) => log::info!("GIT: {repo}/{path} is deleted"),
                GitFileStatus::Untracked(path) => log::info!("GIT: {repo}/{path} is untracked"),
                GitFileStatus::Other { code, path } => {
                    log::info!("GIT: {repo}/{path} has status {code}")
                }
            },
            Status::PkgMissing {
                pkg,
                install_command: Some(cmd),
            } => log::warn!("{pkg} is missing — install with: {cmd}"),
            Status::PkgMissing {
                pkg,
                install_command: None,
            } => log::warn!("{pkg} is missing"),
        }
    }

    fn push_applied_action(&mut self, action: AppliedAction) {
        match action {
            AppliedAction::UpdatedFile(path) => log::info!("GEN: {path} was updated"),
            AppliedAction::CreatedFile(path) => log::info!("GEN: {path} was created"),
            AppliedAction::CreatedSymlink { real, symlink } => {
                log::info!("SYM: created {symlink} <- {real}")
            }
            AppliedAction::CreatedDir(path) => log::info!("DIR: {path} was created"),
        }
    }
}

pub struct DiffLog;

impl Output for DiffLog {
    fn push_status(&mut self, status: Status) {
        match status {
            Status::Generated {
                want_content,
                cur_content,
                path,
                status,
            } => {
                match status {
                    FileStatus::Ok => {
                        log::debug!("GEN: {path} is unchanged");
                        return;
                    }
                    FileStatus::Modified => log::info!("GEN: {path} is modified"),
                    FileStatus::New => log::info!("GEN: {path} is missing"),
                }
                let old = cur_content.as_deref().unwrap_or("");
                let old_label = if cur_content.is_some() {
                    format!("--- {path} (current)")
                } else {
                    "--- /dev/null".to_string()
                };
                eprintln!("{old_label}");
                eprintln!("+++ {path} (generated)");
                let diff = TextDiff::from_lines(old, want_content.as_ref());
                for change in diff.iter_all_changes() {
                    let (prefix, color, reset) = match change.tag() {
                        ChangeTag::Delete => ("-", "\x1b[31m", "\x1b[0m"),
                        ChangeTag::Insert => ("+", "\x1b[32m", "\x1b[0m"),
                        ChangeTag::Equal => (" ", "\x1b[2m", "\x1b[0m"),
                    };
                    eprint!("{color}{prefix}{change}{reset}");
                }
            }
            other => Log.push_status(other),
        }
    }

    fn push_applied_action(&mut self, action: AppliedAction) {
        Log.push_applied_action(action);
    }
}