zenops 0.20.0

Declarative system configuration management for shell config and dotfiles.
//! Package configuration: the `[pkg.<key>]` table.
//!
//! [`PkgConfig`] is the parsed shape; submodules host the supporting
//! sublanguages:
//!
//! - [`detect`] — the `detect = ...` strategy language: file/which leaves,
//!   any/all combinators, and a `when` gate that ties a subtree to a
//!   `[conditions]` entry. Pkg-level host gating lives on `pkg.*.when`.
//! - [`install`] — `install_hint` — per-package-manager install commands.
//! - [`action`] — `shell.{env_init,login_init,interactive_init}` shell-init
//!   action lines and the per-shell routing.

mod action;
mod detect;
mod error;
mod install;

#[cfg(test)]
mod tests;

use indexmap::IndexMap;
use smol_str::SmolStr;

use super::condition::{ConditionOrRef, Conditions, EvalContext};
use super::pkg_config_files::PkgConfigFiles;

pub(crate) use action::PkgShellConfig;
pub use action::{ActionKind, ShellInitAction};
pub use detect::DetectStrategy;
pub use error::Error;
pub use install::InstallHint;

// `Shell` lives in `crate::platform` now — it's a platform fact, not a
// pkg-config concept. Re-exported here so existing callers
// (`use crate::config::pkg::Shell`) keep working unchanged.
pub use crate::platform::Shell;

/// How seriously zenops takes the package being missing.
///
/// Defaults to `on` — you wrote `[pkg.foo]`, so zenops assumes you
/// want foo. Use `detect` for tools you might or might not have; a
/// miss is treated as a non-event. Use `disabled` to switch the
/// entry off without removing the whole section.
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Default)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PkgEnable {
    /// I want this package. zenops checks `detect` and flags a miss
    /// prominently — most likely you forgot to install it.
    #[default]
    On,
    /// Use this package only if it's already installed. A miss is
    /// silent.
    Detect,
    /// Turn this entry off without deleting it from your config.
    Disabled,
}

/// One package — a piece of software you want zenops to know about.
///
/// Each `[pkg.foo]` section tells zenops about one package. zenops uses
/// the section for four things: it checks whether the package is
/// installed (`detect`), tells you how to install it if it's missing
/// (`install_hint`), drops the right startup lines into your generated
/// shell config (`shell`), and manages any config files the package
/// owns (`configs`). A `when` rule scopes all of that to specific
/// machines or operating systems.
#[derive(serde::Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct PkgConfig {
    /// How seriously zenops takes a missing package.
    #[serde(default)]
    pub(super) enable: PkgEnable,
    /// How to tell whether the package is installed. Could be a `which`
    /// lookup on `PATH`, a file existing on disk, or several checks
    /// combined. If you leave this out, zenops assumes the package is
    /// installed.
    #[serde(default)]
    pub(super) detect: Option<DetectStrategy>,
    /// Per-package variables. Anything you set here is available as
    /// `${name}` inside this package's install hints and shell init
    /// lines — handy for paths or version numbers you'd otherwise
    /// repeat.
    #[serde(default)]
    pub(super) inputs: IndexMap<SmolStr, SmolStr>,
    /// Limit this package to specific machines. Name a rule from
    /// `[conditions]`, or write a condition table inline. When the
    /// rule is false, zenops treats the package as not installed and
    /// quietly skips it.
    #[serde(default)]
    pub(super) when: Option<ConditionOrRef>,
    /// A friendlier name to show in listings like `zenops pkg`. Useful
    /// when you have two entries for the same conceptual tool — say a
    /// Homebrew entry gated to macOS and an apt entry gated to Linux —
    /// and want both to read as just `brew`.
    #[serde(default)]
    pub name: Option<SmolStr>,
    /// A free-form note for yourself. zenops shows it in listings.
    #[serde(default)]
    pub description: Option<String>,
    /// How to install this package on each operating system. zenops
    /// won't run these for you, but `zenops pkg` surfaces them when the
    /// package is missing so you have something copy-pastable.
    #[serde(default)]
    pub install_hint: InstallHint,
    /// Startup lines that should land in your shell rc when this
    /// package is installed — typically a `brew shellenv`, an
    /// `eval "$(starship init zsh)"`, or a PATH addition.
    #[serde(default)]
    pub(crate) shell: PkgShellConfig,
    /// Dotfiles or config files this package owns — anything under
    /// `~/.config/<name>/` or `~/<file>`. Applied only when the package
    /// counts as installed.
    #[serde(default)]
    pub(super) configs: Vec<PkgConfigFiles>,
}

impl PkgConfig {
    pub fn is_installed(
        &self,
        conditions: &Conditions,
        ctx: &EvalContext<'_>,
    ) -> Result<bool, Error> {
        if !self.evaluate_when(conditions, ctx)? {
            return Ok(false);
        }
        match self.enable {
            // `on` and `detect` run the same installation check; an absent
            // `detect` field means "nothing to check" → installed. They
            // diverge only in how consumers *surface* a miss: for `on`,
            // `enable_on_but_detect_missing` flags it so callers can push
            // a `Status::Pkg { status: PkgStatus::Missing }` to structured
            // output; `detect` miss is silent by design.
            PkgEnable::On | PkgEnable::Detect => {
                let Some(detect) = self.detect.as_ref() else {
                    return Ok(true);
                };
                let lookup = [&self.inputs, ctx.inputs];
                detect.check(conditions, ctx, &lookup)
            }
            PkgEnable::Disabled => Ok(false),
        }
    }

    /// Config-health predicate: `true` only when the user declared
    /// `enable = "on"` with a detect strategy that doesn't match on the
    /// current host. Rendering layers use this to push a user-facing
    /// "pkg is missing" signal via `Output`. Returns `false` for `detect`
    /// (miss is silent), `disabled`, condition-gated-out pkgs, and `on`
    /// pkgs with absent or matching detect.
    pub fn enable_on_but_detect_missing(
        &self,
        conditions: &Conditions,
        ctx: &EvalContext<'_>,
    ) -> Result<bool, Error> {
        if !matches!(self.enable, PkgEnable::On) {
            return Ok(false);
        }
        if !self.evaluate_when(conditions, ctx)? {
            return Ok(false);
        }
        let Some(detect) = self.detect.as_ref() else {
            return Ok(false);
        };
        let lookup = [&self.inputs, ctx.inputs];
        detect.check(conditions, ctx, &lookup).map(|r| !r)
    }

    /// Complement of [`Self::enable_on_but_detect_missing`] within `enable =
    /// "on"`. True when the user declared `enable = "on"`, there's a detect
    /// strategy, and it matches on the current host — a real positive check
    /// that something got verified. Used to emit a clean-state `Status::Pkg
    /// { status: Ok }` so `zenops status --all` can show the pkg was
    /// looked at. Absent-detect pkgs (e.g. meta/scaffolding configs like
    /// `bashrc-chain`) stay silent: "no detect" means "nothing to report
    /// as verified." Like its counterpart, silent for `detect` /
    /// `disabled` / condition-gated-out pkgs.
    pub fn enable_on_and_detect_matches(
        &self,
        conditions: &Conditions,
        ctx: &EvalContext<'_>,
    ) -> Result<bool, Error> {
        if !matches!(self.enable, PkgEnable::On) {
            return Ok(false);
        }
        if !self.evaluate_when(conditions, ctx)? {
            return Ok(false);
        }
        let Some(detect) = self.detect.as_ref() else {
            return Ok(false);
        };
        let lookup = [&self.inputs, ctx.inputs];
        detect.check(conditions, ctx, &lookup)
    }

    pub fn is_disabled(&self) -> bool {
        matches!(self.enable, PkgEnable::Disabled)
    }

    /// The top-level detect strategy when it matches on the current host —
    /// used for debuggable output. For an `any` / `all` combinator this
    /// returns the combinator itself; consumers that care about the matching
    /// leaf can walk the children themselves.
    pub fn matched_detect(
        &self,
        conditions: &Conditions,
        ctx: &EvalContext<'_>,
    ) -> Result<Option<&DetectStrategy>, Error> {
        if !self.evaluate_when(conditions, ctx)? {
            return Ok(None);
        }
        match self.enable {
            PkgEnable::On | PkgEnable::Detect => {
                if let Some(detect) = self.detect.as_ref() {
                    let lookup = [&self.inputs, ctx.inputs];
                    detect
                        .check(conditions, ctx, &lookup)
                        .map(|r| r.then_some(detect))
                } else {
                    Ok(None)
                }
            }
            PkgEnable::Disabled => Ok(None),
        }
    }

    /// Returns `true` when `when` is unset or evaluates true; `false` when
    /// it evaluates false. The single gate that replaces `supported_os` and
    /// `supported_shells`.
    pub(crate) fn evaluate_when(
        &self,
        conditions: &Conditions,
        ctx: &EvalContext<'_>,
    ) -> Result<bool, Error> {
        match self.when.as_ref() {
            None => Ok(true),
            Some(cor) => Ok(conditions.evaluate(cor, ctx)?),
        }
    }

    pub(crate) fn inputs(&self) -> &IndexMap<SmolStr, SmolStr> {
        &self.inputs
    }

    pub(super) fn configs(&self) -> &[PkgConfigFiles] {
        &self.configs
    }
}