use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditProfile {
pub name: String,
pub before: String,
pub after: String,
pub definition: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ignore_file: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_used_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ProfileStore {
#[serde(default)]
pub profiles: Vec<AuditProfile>,
}
impl ProfileStore {
pub fn load() -> anyhow::Result<Self> {
let path = profile_path()?;
if !path.exists() {
return Ok(Self::default());
}
let text = std::fs::read_to_string(&path)?;
Ok(serde_yaml::from_str(&text)?)
}
pub fn save(&self) -> anyhow::Result<()> {
let path = profile_path()?;
let yaml = serde_yaml::to_string(self)?;
std::fs::write(&path, yaml)?;
Ok(())
}
pub fn add(&mut self, profile: AuditProfile) {
if let Some(pos) = self.profiles.iter().position(|p| p.name == profile.name) {
self.profiles[pos] = profile;
} else {
self.profiles.push(profile);
}
}
pub fn remove(&mut self, name: &str) {
self.profiles.retain(|p| p.name != name);
}
pub fn touch(&mut self, name: &str) -> anyhow::Result<bool> {
if let Some(p) = self.profiles.iter_mut().find(|p| p.name == name) {
p.last_used_at = Some(Utc::now());
self.save()?;
Ok(true)
} else {
Ok(false)
}
}
pub fn sorted_by_recent(&self) -> Vec<&AuditProfile> {
let mut v: Vec<&AuditProfile> = self.profiles.iter().collect();
v.sort_by(|a, b| b.last_used_at.cmp(&a.last_used_at));
v
}
}
fn profile_path() -> anyhow::Result<PathBuf> {
let base = dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?
.join(".aaai");
std::fs::create_dir_all(&base)?;
Ok(base.join("profiles.yaml"))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn make_profile(name: &str) -> AuditProfile {
AuditProfile {
name: name.into(),
before: "/before".into(),
after: "/after".into(),
definition: None,
ignore_file: None,
last_used_at: None,
}
}
#[test]
fn add_and_remove() {
let mut store = ProfileStore::default();
store.add(make_profile("prod"));
assert_eq!(store.profiles.len(), 1);
store.remove("prod");
assert!(store.profiles.is_empty());
}
#[test]
fn add_replaces_same_name() {
let mut store = ProfileStore::default();
let mut p = make_profile("p");
p.before = "/a".into();
store.add(p);
let mut p2 = make_profile("p");
p2.before = "/x".into();
store.add(p2);
assert_eq!(store.profiles.len(), 1);
assert_eq!(store.profiles[0].before, "/x");
}
#[test]
fn last_used_at_defaults_to_none() {
let p = make_profile("p");
assert!(p.last_used_at.is_none(),
"newly-constructed profiles must have last_used_at = None");
}
#[test]
fn legacy_yaml_without_last_used_at_deserialises() {
let legacy_yaml = "\
profiles:
- name: legacy
before: /before
after: /after
definition: null
";
let store: ProfileStore = serde_yaml::from_str(legacy_yaml).unwrap();
assert_eq!(store.profiles.len(), 1);
assert_eq!(store.profiles[0].name, "legacy");
assert!(store.profiles[0].last_used_at.is_none(),
"missing field must default to None");
}
#[test]
fn sorted_by_recent_orders_most_recent_first() {
let mut store = ProfileStore::default();
let mut newer = make_profile("newer");
newer.last_used_at = Some(Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap());
store.profiles.push(newer);
let mut older = make_profile("older");
older.last_used_at = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap());
store.profiles.push(older);
let sorted = store.sorted_by_recent();
assert_eq!(sorted[0].name, "newer");
assert_eq!(sorted[1].name, "older");
}
#[test]
fn sorted_by_recent_pushes_none_to_end() {
let mut store = ProfileStore::default();
store.profiles.push(make_profile("never_used"));
let mut ancient = make_profile("ancient");
ancient.last_used_at = Some(Utc.with_ymd_and_hms(2000, 1, 1, 0, 0, 0).unwrap());
store.profiles.push(ancient);
let sorted = store.sorted_by_recent();
assert_eq!(sorted[0].name, "ancient");
assert_eq!(sorted[1].name, "never_used");
}
#[test]
fn touch_marks_profile_when_found() {
let mut store = ProfileStore::default();
store.profiles.push(make_profile("p"));
assert!(store.profiles[0].last_used_at.is_none());
let before = Utc::now();
if let Some(p) = store.profiles.iter_mut().find(|p| p.name == "p") {
p.last_used_at = Some(Utc::now());
}
let after = Utc::now();
let ts = store.profiles[0].last_used_at.expect("touched");
assert!(ts >= before && ts <= after,
"touched timestamp should fall within the call window");
}
}