enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Medic reference schemas — skeleton YAML files that define allowed structure for ENACT_HOME config.
//! Used for boundary checks (no extra keys) and for `enact doctor --fix` (merge missing keys, reset).

use serde_yaml::Value as YamlValue;
use std::collections::HashSet;

/// Config filenames that have a medic reference.
pub const REFERENCE_FILES: &[&str] = &[
    "config.yaml",
    "channels.yaml",
    "providers.yaml",
    "agent.yaml",
    "workflow.yaml",
    "tools.yaml",
    "cron.yaml",
    "mcp.yaml",
    "a2a.yaml",
    "skills.yaml",
    "context.yaml",
    "memory.yaml",
];

/// Return reference YAML content for a given filename, or None if no reference exists.
/// Content is embedded at compile time so it works for installed CLI.
pub fn reference_yaml(filename: &str) -> Option<&'static str> {
    match filename {
        "config.yaml" => Some(include_str!("../medic/config.yaml")),
        "channels.yaml" => Some(include_str!("../medic/channels.yaml")),
        "providers.yaml" => Some(include_str!("../medic/providers.yaml")),
        "agent.yaml" => Some(include_str!("../medic/agent.yaml")),
        "workflow.yaml" => Some(include_str!("../medic/workflow.yaml")),
        "tools.yaml" => Some(include_str!("../medic/tools.yaml")),
        "cron.yaml" => Some(include_str!("../medic/cron.yaml")),
        "mcp.yaml" => Some(include_str!("../medic/mcp.yaml")),
        "a2a.yaml" => Some(include_str!("../medic/a2a.yaml")),
        "skills.yaml" => Some(include_str!("../medic/skills.yaml")),
        "context.yaml" => Some(include_str!("../medic/context.yaml")),
        "memory.yaml" => Some(include_str!("../medic/memory.yaml")),
        _ => None,
    }
}

/// Compare user YAML (as Value) to reference Value and return keys present in user but not in reference.
/// Only checks top-level keys; optionally one level deep for nested maps (e.g. channels.telegram).
/// Returns list of disallowed key paths, e.g. ["foo"] or ["channels", "channels.unknown_key"].
pub fn disallowed_top_level_keys(user: &YamlValue, reference: &YamlValue) -> Vec<String> {
    let allowed: HashSet<String> = reference
        .as_mapping()
        .map(|m| {
            m.keys()
                .filter_map(|k| k.as_str().map(String::from))
                .collect()
        })
        .unwrap_or_default();
    user.as_mapping()
        .map(|m| {
            m.keys()
                .filter_map(|k| k.as_str().map(String::from))
                .filter(|k| !allowed.contains(k))
                .collect()
        })
        .unwrap_or_default()
}

/// Recursively collect all keys at depth 0 and 1 (for boundary check with one level deep).
/// Returns paths like "channels", "channels.telegram", "runtime.max_concurrent".
pub fn allowed_key_paths_shallow(reference: &YamlValue) -> HashSet<String> {
    let mut out = HashSet::new();
    let map = match reference.as_mapping() {
        Some(m) => m,
        None => return out,
    };
    for (k, v) in map {
        let top = k.as_str().unwrap_or("").to_string();
        out.insert(top.clone());
        if let Some(nested) = v.as_mapping() {
            for (k2, _) in nested {
                let second = k2.as_str().unwrap_or("").to_string();
                out.insert(format!("{}.{}", top, second));
            }
        }
    }
    out
}

/// Same as disallowed_top_level_keys but only top-level; simple and strict.
pub fn extra_top_level_keys(user: &YamlValue, reference: &YamlValue) -> Vec<String> {
    disallowed_top_level_keys(user, reference)
}

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

    #[test]
    fn reference_yaml_returns_some_for_known_files() {
        assert!(reference_yaml("config.yaml").is_some());
        assert!(reference_yaml("channels.yaml").is_some());
        assert!(reference_yaml("workflow.yaml").is_some());
        assert!(reference_yaml("unknown.yaml").is_none());
    }

    #[test]
    fn disallowed_keys_detects_extra() {
        let ref_yaml = "a: 1\nb: 2";
        let user_yaml = "a: 1\nb: 2\nc: 3";
        let ref_val: YamlValue = serde_yaml::from_str(ref_yaml).unwrap();
        let user_val: YamlValue = serde_yaml::from_str(user_yaml).unwrap();
        let disallowed = disallowed_top_level_keys(&user_val, &ref_val);
        assert_eq!(disallowed, vec!["c"]);
    }

    #[test]
    fn disallowed_keys_empty_when_subset() {
        let ref_yaml = "a: 1\nb: 2\nc: 3";
        let user_yaml = "a: 1";
        let ref_val: YamlValue = serde_yaml::from_str(ref_yaml).unwrap();
        let user_val: YamlValue = serde_yaml::from_str(user_yaml).unwrap();
        let disallowed = disallowed_top_level_keys(&user_val, &ref_val);
        assert!(disallowed.is_empty());
    }
}