aaai_core/project/
config.rs1use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22
23pub const CONFIG_FILENAME: &str = ".aaai.yaml";
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct ProjectConfig {
28 #[serde(default = "default_version")]
30 pub version: String,
31
32 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub default_definition: Option<String>,
35
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub default_ignore: Option<String>,
39
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub approver_name: Option<String>,
43
44 #[serde(default)]
46 pub mask_secrets: bool,
47
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub custom_mask_patterns: Vec<String>,
51
52 #[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 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 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 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 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}