Skip to main content

aaai_core/project/
config.rs

1//! Project-level configuration — `.aaai.yaml`.
2//!
3//! Placed in a project's root (e.g. beside the repository's `.git` directory),
4//! `.aaai.yaml` provides defaults so that team members don't need to specify
5//! common paths on every invocation.
6//!
7//! # Example `.aaai.yaml`
8//!
9//! ```yaml
10//! version: "1"
11//! default_definition: "audit/audit.yaml"
12//! default_ignore: "audit/.aaaiignore"
13//! approver_name: "alice"
14//! mask_secrets: true
15//! custom_mask_patterns:
16//!   - "MY_INTERNAL_TOKEN_[A-Z0-9]{16}"
17//! ```
18
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23pub const CONFIG_FILENAME: &str = ".aaai.yaml";
24
25/// The project-level configuration document.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ProjectConfig {
28    /// Schema version. Currently `"1"`.
29    #[serde(default = "default_version")]
30    pub version: String,
31
32    /// Default audit definition path, relative to project root.
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub default_definition: Option<String>,
35
36    /// Default `.aaaiignore` path, relative to project root.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub default_ignore: Option<String>,
39
40    /// Default approver name stamped on approvals (overridden by CLI flag).
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub approver_name: Option<String>,
43
44    /// Enable secret masking by default.
45    #[serde(default)]
46    pub mask_secrets: bool,
47
48    /// Custom regex patterns added to the masking engine.
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub custom_mask_patterns: Vec<String>,
51}
52
53fn default_version() -> String { "1".into() }
54
55impl ProjectConfig {
56    /// Load from `path`.  Returns `None` when the file does not exist.
57    pub fn load(path: &Path) -> anyhow::Result<Option<Self>> {
58        if !path.exists() {
59            return Ok(None);
60        }
61        let text = std::fs::read_to_string(path)
62            .map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
63        let cfg: Self = serde_yaml::from_str(&text)
64            .map_err(|e| anyhow::anyhow!("Invalid {}: {e}", path.display()))?;
65        Ok(Some(cfg))
66    }
67
68    /// Discover `.aaai.yaml` by walking up from `start_dir`.
69    /// Returns the config and the directory in which it was found.
70    pub fn discover(start_dir: &Path) -> anyhow::Result<Option<(Self, PathBuf)>> {
71        let mut dir = start_dir.to_path_buf();
72        loop {
73            let candidate = dir.join(CONFIG_FILENAME);
74            if let Some(cfg) = Self::load(&candidate)? {
75                log::info!("Discovered {} at {}", CONFIG_FILENAME, dir.display());
76                return Ok(Some((cfg, dir)));
77            }
78            match dir.parent() {
79                Some(p) => dir = p.to_path_buf(),
80                None    => return Ok(None),
81            }
82        }
83    }
84
85    /// Write to `path`, creating parent directories as needed.
86    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
87        if let Some(parent) = path.parent() {
88            std::fs::create_dir_all(parent)?;
89        }
90        let yaml = serde_yaml::to_string(self)?;
91        std::fs::write(path, yaml)?;
92        Ok(())
93    }
94
95    /// Generate a starter config with helpful comments embedded as YAML string.
96    pub fn starter_yaml() -> &'static str {
97        r#"# aaai project configuration
98# Place this file at the root of your project.
99version: "1"
100
101# Path to the default audit definition, relative to this file.
102# default_definition: "audit/audit.yaml"
103
104# Path to the default .aaaiignore file, relative to this file.
105# default_ignore: "audit/.aaaiignore"
106
107# Default approver name stamped when approving entries via CLI.
108# approver_name: "your-name"
109
110# Automatically mask secrets in CLI output and reports.
111mask_secrets: false
112
113# Additional regex patterns to mask (beyond built-in patterns).
114# custom_mask_patterns:
115#   - "MY_INTERNAL_[A-Z0-9]{16}"
116"#
117    }
118}
119
120#[cfg(test)]
121mod tests {
122    use super::*;
123
124    #[test]
125    fn round_trip_yaml() {
126        let cfg = ProjectConfig {
127            version: "1".into(),
128            default_definition: Some("audit/audit.yaml".into()),
129            approver_name: Some("alice".into()),
130            mask_secrets: true,
131            custom_mask_patterns: vec!["PATTERN_[A-Z]+".into()],
132            ..Default::default()
133        };
134        let yaml = serde_yaml::to_string(&cfg).unwrap();
135        let restored: ProjectConfig = serde_yaml::from_str(&yaml).unwrap();
136        assert_eq!(restored.approver_name.as_deref(), Some("alice"));
137        assert!(restored.mask_secrets);
138        assert_eq!(restored.custom_mask_patterns.len(), 1);
139    }
140
141    #[test]
142    fn load_nonexistent_returns_none() {
143        let result = ProjectConfig::load(Path::new("/nonexistent/.aaai.yaml")).unwrap();
144        assert!(result.is_none());
145    }
146}