aaai_core/profile/
store.rs1use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuditProfile {
11 pub name: String,
13 pub before: String,
14 pub after: String,
15 pub definition: Option<String>,
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub ignore_file: Option<String>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub last_used_at: Option<DateTime<Utc>>,
24}
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct ProfileStore {
29 #[serde(default)]
30 pub profiles: Vec<AuditProfile>,
31}
32
33impl ProfileStore {
34 pub fn load() -> anyhow::Result<Self> {
36 let path = profile_path()?;
37 if !path.exists() {
38 return Ok(Self::default());
39 }
40 let text = std::fs::read_to_string(&path)?;
41 Ok(serde_yaml::from_str(&text)?)
42 }
43
44 pub fn save(&self) -> anyhow::Result<()> {
46 let path = profile_path()?;
47 let yaml = serde_yaml::to_string(self)?;
48 std::fs::write(&path, yaml)?;
49 Ok(())
50 }
51
52 pub fn add(&mut self, profile: AuditProfile) {
53 if let Some(pos) = self.profiles.iter().position(|p| p.name == profile.name) {
55 self.profiles[pos] = profile;
56 } else {
57 self.profiles.push(profile);
58 }
59 }
60
61 pub fn remove(&mut self, name: &str) {
62 self.profiles.retain(|p| p.name != name);
63 }
64
65 pub fn touch(&mut self, name: &str) -> anyhow::Result<bool> {
72 if let Some(p) = self.profiles.iter_mut().find(|p| p.name == name) {
73 p.last_used_at = Some(Utc::now());
74 self.save()?;
75 Ok(true)
76 } else {
77 Ok(false)
78 }
79 }
80
81 pub fn sorted_by_recent(&self) -> Vec<&AuditProfile> {
85 let mut v: Vec<&AuditProfile> = self.profiles.iter().collect();
86 v.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
89 v
90 }
91}
92
93fn profile_path() -> anyhow::Result<PathBuf> {
94 let base = dirs::home_dir()
95 .ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
96 .join(".aaai");
97 std::fs::create_dir_all(&base)?;
98 Ok(base.join("profiles.yaml"))
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use chrono::TimeZone;
105
106 fn make_profile(name: &str) -> AuditProfile {
107 AuditProfile {
108 name: name.into(),
109 before: "/before".into(),
110 after: "/after".into(),
111 definition: None,
112 ignore_file: None,
113 last_used_at: None,
114 }
115 }
116
117 #[test]
118 fn add_and_remove() {
119 let mut store = ProfileStore::default();
120 store.add(make_profile("prod"));
121 assert_eq!(store.profiles.len(), 1);
122 store.remove("prod");
123 assert!(store.profiles.is_empty());
124 }
125
126 #[test]
127 fn add_replaces_same_name() {
128 let mut store = ProfileStore::default();
129 let mut p = make_profile("p");
130 p.before = "/a".into();
131 store.add(p);
132 let mut p2 = make_profile("p");
133 p2.before = "/x".into();
134 store.add(p2);
135 assert_eq!(store.profiles.len(), 1);
136 assert_eq!(store.profiles[0].before, "/x");
137 }
138
139 #[test]
142 fn last_used_at_defaults_to_none() {
143 let p = make_profile("p");
144 assert!(p.last_used_at.is_none(),
145 "newly-constructed profiles must have last_used_at = None");
146 }
147
148 #[test]
149 fn legacy_yaml_without_last_used_at_deserialises() {
150 let legacy_yaml = "\
153profiles:
154 - name: legacy
155 before: /before
156 after: /after
157 definition: null
158";
159 let store: ProfileStore = serde_yaml::from_str(legacy_yaml).unwrap();
160 assert_eq!(store.profiles.len(), 1);
161 assert_eq!(store.profiles[0].name, "legacy");
162 assert!(store.profiles[0].last_used_at.is_none(),
163 "missing field must default to None");
164 }
165
166 #[test]
167 fn sorted_by_recent_orders_most_recent_first() {
168 let mut store = ProfileStore::default();
169
170 let mut newer = make_profile("newer");
171 newer.last_used_at = Some(Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap());
172 store.profiles.push(newer);
173
174 let mut older = make_profile("older");
175 older.last_used_at = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
176 store.profiles.push(older);
177
178 let sorted = store.sorted_by_recent();
179 assert_eq!(sorted[0].name, "newer");
180 assert_eq!(sorted[1].name, "older");
181 }
182
183 #[test]
184 fn sorted_by_recent_pushes_none_to_end() {
185 let mut store = ProfileStore::default();
188
189 store.profiles.push(make_profile("never_used")); let mut ancient = make_profile("ancient");
192 ancient.last_used_at = Some(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap());
193 store.profiles.push(ancient);
194
195 let sorted = store.sorted_by_recent();
196 assert_eq!(sorted[0].name, "ancient");
197 assert_eq!(sorted[1].name, "never_used");
198 }
199
200 #[test]
207 fn touch_marks_profile_when_found() {
208 let mut store = ProfileStore::default();
212 store.profiles.push(make_profile("p"));
213 assert!(store.profiles[0].last_used_at.is_none());
214
215 let before = Utc::now();
216 if let Some(p) = store.profiles.iter_mut().find(|p| p.name == "p") {
217 p.last_used_at = Some(Utc::now());
218 }
219 let after = Utc::now();
220
221 let ts = store.profiles[0].last_used_at.expect("touched");
222 assert!(ts >= before && ts <= after,
223 "touched timestamp should fall within the call window");
224 }
225}