Skip to main content

aaai_core/profile/
store.rs

1//! Audit profiles — named before/after/definition combos saved to
2//! `~/.aaai/profiles.yaml`.
3
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// One saved audit project configuration.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AuditProfile {
11    /// Display name for this profile.
12    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    /// RFC 023 §3.2: last-used timestamp for "Recent projects" ordering.
19    /// `#[serde(default)]` keeps legacy `~/.aaai/profiles.yaml` files
20    /// (without this field) loading cleanly — they appear as `None`,
21    /// which `sorted_by_recent` treats as the oldest entries.
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub last_used_at: Option<DateTime<Utc>>,
24}
25
26/// Root document for `~/.aaai/profiles.yaml`.
27#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28pub struct ProfileStore {
29    #[serde(default)]
30    pub profiles: Vec<AuditProfile>,
31}
32
33impl ProfileStore {
34    /// Load from `~/.aaai/profiles.yaml`, returning empty store if absent.
35    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    /// Save to `~/.aaai/profiles.yaml`.
45    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        // Replace existing profile with the same name.
54        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    /// RFC 023 §3.2: stamp the profile's `last_used_at` to now and persist.
66    ///
67    /// Looks up by name (the same key `add()` uses for replace-on-match).
68    /// Returns `Ok(true)` if a profile with that name was found and
69    /// updated, `Ok(false)` if no such profile exists (the store is left
70    /// unchanged). Errors only on I/O during save.
71    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    /// RFC 023 §3.2: profiles sorted by `last_used_at` descending.
82    /// Profiles without a `last_used_at` (legacy entries) come last,
83    /// in their original insertion order relative to each other.
84    pub fn sorted_by_recent(&self) -> Vec<&AuditProfile> {
85        let mut v: Vec<&AuditProfile> = self.profiles.iter().collect();
86        // `Option<DateTime<Utc>>` orders `None < Some(_)`, so reverse-cmp
87        // pushes `None` to the end naturally.
88        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    // ── RFC 023 §3.2: last_used_at + sorted_by_recent ────────────────────
140
141    #[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        // RFC 023 NFR-3: a profile YAML missing `last_used_at` must
151        // still load — older versions of aaai didn't write that field.
152        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        // Legacy profiles (last_used_at = None) sort after any
186        // profile with a real timestamp — even an ancient one.
187        let mut store = ProfileStore::default();
188
189        store.profiles.push(make_profile("never_used"));  // None
190
191        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    // touch() unit-tests its bookkeeping by working on the in-memory
201    // store; the `save()` call inside touch() touches the home directory,
202    // so we deliberately avoid going through `touch()` here and exercise
203    // the equivalent assignment instead. A full touch() test would need
204    // a fakeable filesystem hook, which is out of scope for v0.20.
205
206    #[test]
207    fn touch_marks_profile_when_found() {
208        // Verifies the lookup + assignment logic without persisting,
209        // by inlining the body of touch(): real touch() also calls
210        // save() which we avoid in tests.
211        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}