pupoxide 0.2.2

A high-performance, memory-safe, declarative configuration management tool inspired by Puppet.
Documentation
use crate::domain::Facts;
use crate::domain::error::Result;
use serde::{Deserialize, Serialize};
use yaml_serde::Value;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tracing::{debug, warn};

/// Root configuration schema for the hierarchical stash.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StashConfig {
    /// Schema format version.
    pub version: u32,
    /// Default values used when a key is not found in the hierarchy.
    pub defaults: Option<HashMap<String, Value>>,
    /// Ordered hierarchy levels to search for values.
    pub hierarchy: Vec<HierarchyEntry>,
}

/// A single hierarchy level representing files or directories to lookup.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HierarchyEntry {
    /// Friendly name of this hierarchy level.
    pub name: String,
    /// A single file path template (e.g. `"nodes/{{facts.hostname}}.yaml"`).
    pub path: Option<String>,
    /// A list of multiple file path templates.
    pub paths: Option<Vec<String>>,
}

/// Concrete file system adapter implementing `StashProvider`.
///
/// Loads configurations dynamically from `stash.yaml` and executes hierarchical,
/// Tera-templated lookup against files in the `data/` directory.
#[derive(Clone)]
pub struct Stash {
    _config_path: PathBuf,
    data_dir: PathBuf,
    config: StashConfig,
    tera: Arc<std::sync::Mutex<tera::Tera>>,
}

impl Stash {
    /// Attempts to instantiate a new `Stash` from the environment directory path.
    ///
    /// Returns `None` if `stash.yaml` does not exist in the target directory.
    pub fn new(environment_path: PathBuf) -> Result<Option<Self>> {
        let config_path = environment_path.join("stash.yaml");
        if !config_path.exists() {
            return Ok(None);
        }

        let content = std::fs::read_to_string(&config_path)
            .map_err(|e| anyhow::anyhow!("Failed to read stash.yaml: {}", e))?;

        let config: StashConfig = yaml_serde::from_str(&content)
            .map_err(|e| anyhow::anyhow!("Failed to parse stash.yaml: {}", e))?;

        let data_dir = environment_path.join("data");

        Ok(Some(Self {
            _config_path: config_path,
            data_dir,
            config,
            tera: Arc::new(std::sync::Mutex::new(tera::Tera::default())),
        }))
    }

    /// Performs a hierarchical search for a value by key based on collected facts.
    ///
    /// The lookup algorithm performs the following steps:
    /// 1. Iterates through all hierarchy entries (`hierarchy`) described in `stash.yaml`.
    /// 2. Interpolates each file path pattern using Tera based on the current facts (e.g., `os/{{facts.os_family}}.yaml` becomes `os/Darwin.yaml`).
    /// 3. Resolves the interpolated path against files in the `data/` folder.
    /// 4. If the file exists and contains the requested key, the value is immediately extracted and returned.
    /// 5. If the key is not found in any files of the hierarchy, returns the configured default value (`defaults`), if any.
    pub fn lookup(&self, key: &str, facts: &Facts) -> Option<Value> {
        debug!("Stash lookup for key: {}", key);

        for entry in &self.config.hierarchy {
            if let Some(value) = self.lookup_in_entry(entry, key, facts) {
                return Some(value);
            }
        }

        // Check defaults if configured
        self.config
            .defaults
            .as_ref()
            .and_then(|defaults| defaults.get(key).cloned())
    }

    fn lookup_in_entry(&self, entry: &HierarchyEntry, key: &str, facts: &Facts) -> Option<Value> {
        let mut paths = Vec::new();
        if let Some(p) = &entry.path {
            paths.push(p.clone());
        }
        if let Some(ps) = &entry.paths {
            paths.extend(ps.clone());
        }

        for path_pattern in paths {
            let path_str = self.interpolate(&path_pattern, facts);
            let file_path = self.data_dir.join(&path_str);

            debug!("Checking Stash file: {:?}", file_path);

            if file_path.exists() {
                match self.read_yaml_value(&file_path, key) {
                    Ok(Some(v)) => return Some(v),
                    Ok(None) => continue,
                    Err(e) => {
                        warn!("Error reading Stash file {:?}: {}", file_path, e);
                        continue;
                    }
                }
            }
        }

        None
    }

    fn interpolate(&self, pattern: &str, facts: &Facts) -> String {
        let mut context = tera::Context::new();
        context.insert("facts", &facts.values);

        let mut tera = self.tera.lock().expect("Failed to lock Tera instance");
        match tera.render_str(pattern, &context) {
            Ok(rendered) => rendered,
            Err(e) => {
                warn!("Tera interpolation error for pattern '{}': {}", pattern, e);
                pattern.to_string()
            }
        }
    }

    fn read_yaml_value(&self, path: &PathBuf, key: &str) -> Result<Option<Value>> {
        let content = std::fs::read_to_string(path)
            .map_err(|e| anyhow::anyhow!("Failed to read data file: {}", e))?;

        // We parse as generic Value to handle scalar, array, or map
        let yaml_map: yaml_serde::Value = yaml_serde::from_str(&content)
            .map_err(|e| anyhow::anyhow!("Failed to parse data file: {}", e))?;

        match yaml_map {
            Value::Mapping(map) => {
                let key_val = yaml_serde::Value::String(key.to_string());
                Ok(map.get(&key_val).cloned())
            }
            _ => Ok(None),
        }
    }
}

impl crate::application::StashProvider for Stash {
    fn lookup(&self, key: &str, facts: &crate::domain::facts::Facts) -> Option<yaml_serde::Value> {
        self.lookup(key, facts)
    }
}