mise 2026.9.4

Dev tools, env vars, and tasks in one CLI
//! `[history]`: the operational history configuration read from the system
//! and global configuration layers only. Project configuration found from
//! the current directory never contributes, so no project can change what
//! personal history captures.

use std::path::{Path, PathBuf};

use eyre::{Result, WrapErr};
use indexmap::IndexMap;
use serde::Deserialize;

use crate::config::config_file::mise_toml::MiseToml;
use crate::file::display_path;

/// `[history]` as parsed from a single mise.toml.
#[derive(Debug, Default, Clone, Deserialize)]
pub(crate) struct HistoryTomlConfig {
    /// Globs (with `~`) never captured; `!glob` re-includes.
    #[serde(default)]
    pub exclude: Vec<String>,
    /// Commands to run after a rollback touches a matching path.
    #[serde(default)]
    pub reload: IndexMap<String, String>,
    /// The setup repository this machine publishes to and fetches from.
    #[serde(default)]
    pub origin: Option<OriginTomlConfig>,
    #[serde(default)]
    pub encryption: Option<FileEncryptionConfig>,
}

/// Public recipients shared by every encrypted dotfile.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct FileEncryptionConfig {
    #[serde(default)]
    pub recipients: Vec<String>,
}

pub(crate) fn file_recipients() -> Result<Vec<String>> {
    let mut recipients = Vec::new();
    for (path, layer) in layers()? {
        if let Some(encryption) = layer.encryption {
            if !crate::config::config_file::is_trusted(&path) {
                eyre::bail!(
                    "trust the configuration before using its encryption recipients: {}",
                    display_path(&path)
                );
            }
            recipients = encryption.recipients;
            if recipients.is_empty() {
                eyre::bail!(
                    "[history.encryption].recipients must not be empty; configure recipients before capturing encrypted files"
                );
            }
        }
    }
    Ok(recipients)
}

/// `[history.origin]`.
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct OriginTomlConfig {
    pub url: String,
    #[serde(default = "default_branch")]
    pub branch: String,
}

impl OriginTomlConfig {
    /// A plaintext connection, as recorded before any declaration.
    pub(crate) fn plain(url: String, branch: String) -> Self {
        Self { url, branch }
    }
}

fn default_branch() -> String {
    "main".to_string()
}

/// The effective `[history.origin]`: the last layer that declares one.
pub(crate) fn origin() -> Result<Option<(PathBuf, OriginTomlConfig)>> {
    let mut found = None;
    for (path, layer) in layers()? {
        if let Some(origin) = layer.origin {
            super::sync::network::validate_url(&origin.url)?;
            found = Some((path, origin));
        }
    }
    Ok(found)
}

/// The `[history]` tables of the system and global layers, in discovery
/// order, each with the file that declared it.
pub(crate) fn layers() -> Result<Vec<(PathBuf, HistoryTomlConfig)>> {
    let mut layers = vec![];
    let files = crate::config::system_config_files()
        .into_iter()
        .chain(crate::config::global_config_files())
        .filter(|path| path.is_file())
        .filter(|path| path.extension().is_some_and(|ext| ext == "toml"));
    for path in files {
        if let Some(history) = read_layer(&path)? {
            layers.push((path, history));
        }
    }
    Ok(layers)
}

fn read_layer(path: &Path) -> Result<Option<HistoryTomlConfig>> {
    // Ignoring a malformed later layer could restore obsolete recipients or
    // remove exclusions. Fail closed instead of falling back to another policy.
    let toml = MiseToml::from_file(path)
        .wrap_err_with(|| format!("cannot read history configuration: {}", display_path(path)))?;
    Ok(toml.history_config())
}

/// The effective reload map: glob -> command, a later layer overriding an
/// earlier one for the same glob. Read from the trusted layers only, and
/// resolved before an operation begins so nothing it writes can change it.
pub(crate) fn reload_commands() -> Result<IndexMap<String, String>> {
    let mut commands = IndexMap::new();
    for (path, layer) in layers()? {
        if !crate::config::config_file::is_trusted(&path) {
            if !layer.reload.is_empty() {
                warn!(
                    "history: ignoring [history.reload] in untrusted {}",
                    display_path(&path)
                );
            }
            continue;
        }
        for (glob, command) in &layer.reload {
            commands.insert(glob.clone(), command.clone());
        }
    }
    Ok(commands)
}

/// The effective `exclude` list: every layer's globs in order, later
/// layers after earlier ones, repeats included. Patterns apply in order
/// and the last match wins, so `!glob` re-includes what an earlier glob
/// excluded and a repeated glob excludes again what a `!glob` in between
/// re-included.
pub(crate) fn exclude_globs() -> Result<Vec<String>> {
    let mut globs: Vec<String> = vec![];
    for (_, layer) in layers()? {
        globs.extend(layer.exclude.iter().cloned());
    }
    Ok(globs)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn malformed_encryption_is_rejected_and_layer_errors_propagate() {
        let error = toml::from_str::<HistoryTomlConfig>("[encryption]\nrecipents = ['typo']\n")
            .unwrap_err();
        assert!(error.to_string().contains("recipents"));
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("config.toml");
        std::fs::write(&path, "[history.encryption]\nrecipents = ['typo']\n").unwrap();
        let error = read_layer(&path).unwrap_err();
        assert!(format!("{error:#}").contains("config.toml"));
    }
}