zenops 0.9.0

Declarative system configuration management for shell config and dotfiles.
Documentation
mod ansi;
mod config;
pub mod config_files;
mod doctor;
pub mod error;
pub mod git;
mod init;
pub mod output;
pub mod pkg_list;
pub mod pkg_manager;
pub mod prompt;
pub mod schema;

use std::io::IsTerminal;

use clap::Subcommand;
use xshell::Shell;

use crate::{
    config::Config,
    config_files::{ConfigFileDirs, ConfigFiles},
    error::Error,
    git::{Git, GitCmd},
    output::Output,
    prompt::{DryRunPrompter, PreApplyDecision, Prompter, TerminalPrompter, YesPrompter},
};

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
#[clap(rename_all = "lower")]
pub enum ColorChoice {
    #[default]
    Auto,
    Always,
    Never,
}

impl ColorChoice {
    /// Resolve to a concrete on/off decision. Pass `stream_is_terminal`
    /// for the stream colors will actually be emitted to (stderr for
    /// the terminal renderer and the prompter, stdout for `pkg_list`).
    pub fn enabled(self, stream_is_terminal: bool) -> bool {
        match self {
            Self::Always => true,
            Self::Never => false,
            Self::Auto => std::env::var_os("NO_COLOR").is_none() && stream_is_terminal,
        }
    }
}

#[derive(clap::Args, Debug)]
pub struct Args {
    /// When to colorize output
    #[clap(long, global = true, value_enum, default_value_t = ColorChoice::Auto)]
    pub color: ColorChoice,
}

#[derive(Subcommand, Debug)]
pub enum Cmd {
    Apply {
        /// Pull the latest version of the config using git pull --rebase in the zenops config directory
        #[clap(long, short)]
        pull_config: bool,
        /// Apply every change without prompting.
        #[clap(long, short = 'y', conflicts_with = "dry_run")]
        yes: bool,
        /// Show each prompt with its diff, but apply nothing.
        #[clap(long, short = 'n')]
        dry_run: bool,
        /// Proceed even when the zenops config repo has uncommitted changes.
        /// Required alongside `--yes` when the repo is dirty; without it,
        /// `--yes` on a dirty repo aborts so automation surfaces divergence
        /// instead of silently applying uncommitted state.
        #[clap(long)]
        allow_dirty: bool,
    },
    Status {
        /// Show a diff of what would change
        #[clap(long, short = 'd')]
        diff: bool,
        /// Also list items that already match the desired state
        #[clap(long, short = 'a')]
        all: bool,
    },
    /// List every configured package and whether its dependencies are met
    Pkg {
        /// Include packages with `enable = "disabled"`
        #[clap(long)]
        all: bool,
        /// Show every install hint, not just the one for the detected package manager
        #[clap(long)]
        all_hints: bool,
        /// Show diagnostic details (the detect strategy that matched)
        #[clap(long, short)]
        verbose: bool,
    },
    Repo {
        #[command(subcommand)]
        command: GitCmd,
    },
    /// Clone an existing zenops config repo into `~/.config/zenops` and
    /// validate that it has a `config.toml`. Run this once on a new machine
    /// before `zenops apply`. Authentication (SSH key, HTTPS credential
    /// helper) uses whatever git is already configured to use.
    Init {
        /// Git URL to clone (SSH or HTTPS). Passed verbatim to `git clone`.
        url: String,
        /// Check out this branch or tag after cloning (default: remote's HEAD).
        #[clap(long, short)]
        branch: Option<String>,
        /// After cloning, run `zenops apply`.
        #[clap(long)]
        apply: bool,
        /// With `--apply`, apply every change without prompting (equivalent
        /// to `zenops apply --yes`). Only meaningful together with `--apply`.
        #[clap(long, short = 'y', requires = "apply")]
        yes: bool,
    },
    /// Diagnose the local environment: config dir, git, shell, package
    /// manager, and package health. Read-only; keeps running even when
    /// `config.toml` is missing or fails to parse, so it stays useful on a
    /// broken machine.
    Doctor,
    /// Dump JSON Schema for every structured surface (command output events
    /// and the `config.toml` input) as a single bundle to stdout. The schema
    /// shape is versioned under the zenops crate version embedded in the
    /// bundle.
    Schema,
    /// Print a shell completion script for zenops to stdout.
    ///
    /// Normally sourced automatically by the built-in `zenops` pkg; you
    /// don't need to invoke this by hand.
    Completions {
        /// Shell to generate completions for
        shell: clap_complete::Shell,
    },
}

impl Cmd {
    fn should_update_self(&self, _args: &Args) -> bool {
        match self {
            Cmd::Apply { pull_config, .. } => *pull_config,
            Cmd::Status { .. }
            | Cmd::Pkg { .. }
            | Cmd::Repo { .. }
            | Cmd::Init { .. }
            | Cmd::Doctor
            | Cmd::Schema
            | Cmd::Completions { .. } => false,
        }
    }
}

fn build_prompter(yes: bool, dry_run: bool, color: bool) -> Result<Box<dyn Prompter>, Error> {
    if dry_run {
        Ok(Box::new(DryRunPrompter::new(color)))
    } else if yes {
        Ok(Box::new(YesPrompter))
    } else if std::io::stdin().is_terminal() {
        Ok(Box::new(TerminalPrompter::new(color)))
    } else {
        Err(Error::ApplyNeedsYesOrTty)
    }
}

pub fn real_main(
    args: &Args,
    command: &Cmd,
    dirs: &ConfigFileDirs,
    output: &mut dyn Output,
) -> Result<(), Error> {
    if let Cmd::Completions { .. } = command {
        // Handled by main.rs where the top-level `Cli` is in scope;
        // real_main must not touch config because completions run at every
        // interactive shell startup.
        return Ok(());
    }
    if let Cmd::Init {
        url,
        branch,
        apply,
        yes,
    } = command
    {
        // Init runs before a config.toml exists, so it cannot go through the
        // normal Config::load path below.
        return init::run(url, branch.as_deref(), *apply, *yes, dirs, args, output);
    }
    if let Cmd::Doctor = command {
        // Doctor must survive a missing or broken config.toml — it's the
        // command the user runs when things are wrong. Dispatch before
        // Config::load so load failures can be caught and rendered with
        // actionable hints inside doctor::run.
        let sh = Shell::new().unwrap();
        return doctor::run(args, dirs, &sh, output);
    }
    if let Cmd::Schema = command {
        return schema::run(&mut std::io::stdout().lock());
    }
    let sh = Shell::new().unwrap();
    let config = Config::load(dirs, &sh, command.should_update_self(args))?;
    let mut config_files = ConfigFiles::new(dirs);

    match command {
        Cmd::Apply {
            pull_config: _,
            yes,
            dry_run,
            allow_dirty,
        } => {
            let stderr_color = args.color.enabled(std::io::stderr().is_terminal());
            let mut prompter = build_prompter(*yes, *dry_run, stderr_color)?;
            config.push_pkg_health(output)?;

            let git = Git::new(dirs.zenops(), &sh);
            if git.is_git_repo()? && git.has_uncommitted_changes()? {
                config.check_own_status(&sh, output)?;
                // `--yes` without `--allow-dirty` aborts so CI/cron surface
                // divergence instead of silently applying uncommitted state.
                // `--dry-run` writes nothing, so it's always safe to continue.
                // `--allow-dirty` in any mode bypasses the prompt entirely.
                if *yes && !*allow_dirty {
                    return Err(Error::DirtyRepoRequiresAllowDirty(
                        dirs.zenops().to_path_buf(),
                    ));
                }
                if !*allow_dirty {
                    git.print_pre_apply_summary(stderr_color)?;
                    match prompter.confirm_pre_apply()? {
                        PreApplyDecision::CommitAndPush { message } => {
                            git.commit_all_and_push(&message)?;
                        }
                        PreApplyDecision::Continue => {}
                        PreApplyDecision::Abort => return Ok(()),
                    }
                }
            }

            config.update_config_files(&sh, &mut config_files)?;
            config_files.apply_changes(output, prompter.as_mut())?;
        }
        Cmd::Status { diff: _, all: _ } => {
            config.push_pkg_health(output)?;
            config.check_own_status(&sh, output)?;
            config.update_config_files(&sh, &mut config_files)?;
            config_files.check_status(output)?;
        }
        Cmd::Pkg {
            all,
            all_hints,
            verbose,
        } => {
            pkg_list::push(
                &config,
                pkg_list::Options {
                    all: *all,
                    all_hints: *all_hints,
                    verbose: *verbose,
                },
                output,
            )?;
        }
        Cmd::Repo { command } => {
            command.passthru_dispatch_in(dirs.zenops(), &sh)?;
        }
        Cmd::Init { .. } => unreachable!("handled before Config::load"),
        Cmd::Doctor => unreachable!("handled before Config::load"),
        Cmd::Schema => unreachable!("handled before Config::load"),
        Cmd::Completions { .. } => unreachable!("handled before Config::load"),
    }

    Ok(())
}