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    /// Warning kind IDs to suppress (e.g. ["no-approver", "no-strategy"]).
53    /// Suppressed warnings are not emitted even if the condition is met.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub suppress_warnings: Vec<String>,
56}
57
58fn default_version() -> String { "1".into() }
59
60impl ProjectConfig {
61    /// Load from `path`.  Returns `None` when the file does not exist.
62    pub fn load(path: &Path) -> anyhow::Result<Option<Self>> {
63        if !path.exists() {
64            return Ok(None);
65        }
66        let text = std::fs::read_to_string(path)
67            .map_err(|e| anyhow::anyhow!("Cannot read {}: {e}", path.display()))?;
68        let cfg: Self = serde_yaml::from_str(&text)
69            .map_err(|e| anyhow::anyhow!("Invalid {}: {e}", path.display()))?;
70        Ok(Some(cfg))
71    }
72
73    /// Discover `.aaai.yaml` by walking up from `start_dir`.
74    /// Returns the config and the directory in which it was found.
75    pub fn discover(start_dir: &Path) -> anyhow::Result<Option<(Self, PathBuf)>> {
76        let mut dir = start_dir.to_path_buf();
77        loop {
78            let candidate = dir.join(CONFIG_FILENAME);
79            if let Some(cfg) = Self::load(&candidate)? {
80                log::info!("Discovered {} at {}", CONFIG_FILENAME, dir.display());
81                return Ok(Some((cfg, dir)));
82            }
83            match dir.parent() {
84                Some(p) => dir = p.to_path_buf(),
85                None    => return Ok(None),
86            }
87        }
88    }
89
90    /// Write to `path`, creating parent directories as needed.
91    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
92        if let Some(parent) = path.parent() {
93            std::fs::create_dir_all(parent)?;
94        }
95        let yaml = serde_yaml::to_string(self)?;
96        std::fs::write(path, yaml)?;
97        Ok(())
98    }
99
100    /// Generate a starter config with helpful comments embedded as YAML string.
101    /// Check if a warning kind is suppressed.
102    pub fn is_warning_suppressed(&self, kind: &str) -> bool {
103        self.suppress_warnings.iter().any(|k| k == kind)
104    }
105
106    pub fn starter_yaml() -> &'static str {
107        r#"# aaai project configuration
108# Place this file at the root of your project.
109version: "1"
110
111# Path to the default audit definition, relative to this file.
112# default_definition: "audit/audit.yaml"
113
114# Path to the default .aaaiignore file, relative to this file.
115# default_ignore: "audit/.aaaiignore"
116
117# Default approver name stamped when approving entries via CLI.
118# approver_name: "your-name"
119
120# Automatically mask secrets in CLI output and reports.
121mask_secrets: false
122
123# Additional regex patterns to mask (beyond built-in patterns).
124# custom_mask_patterns:
125#   - "MY_INTERNAL_[A-Z0-9]{16}"
126
127# Warning kinds to suppress.
128# suppress_warnings:
129#   - "no-approver"
130#   - "no-strategy"
131"#
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn round_trip_yaml() {
141        let cfg = ProjectConfig {
142            version: "1".into(),
143            default_definition: Some("audit/audit.yaml".into()),
144            approver_name: Some("alice".into()),
145            mask_secrets: true,
146            custom_mask_patterns: vec!["PATTERN_[A-Z]+".into()],
147            ..Default::default()
148        };
149        let yaml = serde_yaml::to_string(&cfg).unwrap();
150        let restored: ProjectConfig = serde_yaml::from_str(&yaml).unwrap();
151        assert_eq!(restored.approver_name.as_deref(), Some("alice"));
152        assert!(restored.mask_secrets);
153        assert_eq!(restored.custom_mask_patterns.len(), 1);
154    }
155
156    #[test]
157    fn load_nonexistent_returns_none() {
158        let result = ProjectConfig::load(Path::new("/nonexistent/.aaai.yaml")).unwrap();
159        assert!(result.is_none());
160    }
161}