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
53fn default_version() -> String { "1".into() }
54
55impl ProjectConfig {
56 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 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 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 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}