//! Project aggregation: which agent works on which project. Derived from //! the event journal (project field), with a best-effort cache written to //! projects.json (refreshed by 'am doctor --fix'). use crate::app::App; use crate::config::ProjectProfile; use crate::events::{self, EventKind}; use serde::Serialize; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone, Default, Serialize)] pub struct ProjectSummary { pub root: String, pub agents: Vec, pub dominant: Option, pub sessions: u64, pub launches: u64, pub total_duration_s: u64, pub last_activity: Option, } /// Cache file (regenerated on read; deleted by doctor --fix). pub fn cache_file(app: &App) -> PathBuf { app.events_dir().join("projects.json") } /// Aggregate the journal into per-project summaries, sorted by last /// activity (most recent first). Also refreshes the cache file. pub fn aggregate(app: &App) -> Vec { let mut map: BTreeMap = BTreeMap::new(); let mut agent_counts: BTreeMap> = BTreeMap::new(); let mut sessions: BTreeMap> = BTreeMap::new(); for ev in events::read_events(&app.events_dir(), 0) { let Some(project) = ev.project else { continue; }; let s = map .entry(project.clone()) .or_insert_with(|| ProjectSummary { root: project.clone(), ..Default::default() }); if let Some(a) = ev.agent { *agent_counts .entry(project.clone()) .or_default() .entry(a) .or_default() += 1; } if let Some(sid) = ev.session { sessions.entry(project.clone()).or_default().insert(sid); } match ev.kind { EventKind::Start | EventKind::Run => s.launches += 1, _ => {} } if let Some(d) = ev.duration_s { s.total_duration_s += d; } if ev.ts > s.last_activity.clone().unwrap_or_default() { s.last_activity = Some(ev.ts); } } for (p, s) in map.iter_mut() { s.agents = agent_counts .get(p) .map(|c| c.keys().cloned().collect()) .unwrap_or_default(); s.agents.sort(); s.dominant = agent_counts .get(p) .and_then(|c| c.iter().max_by_key(|(_, n)| *n).map(|(a, _)| a.clone())); s.sessions = sessions.get(p).map(|set| set.len() as u64).unwrap_or(0); } let mut out: Vec = map.into_values().collect(); out.sort_by(|a, b| b.last_activity.cmp(&a.last_activity)); // Best-effort cache (readable by scripts and backups). if let Ok(text) = serde_json::to_string_pretty(&out) { let _ = std::fs::write(cache_file(app), text); } out } /// The project profile whose root matches cwd (the longest root wins). pub fn profile_for<'a>(app: &'a App, cwd: &Path) -> Option<(&'a str, &'a ProjectProfile)> { let mut best: Option<(&str, &ProjectProfile)> = None; for (name, p) in &app.config.projects { let root = crate::config::expand_path(&p.root); let matches = cwd.starts_with(&root) || cwd == root; if matches { let better = best .map(|(_, b)| { let broot = crate::config::expand_path(&b.root); root.components().count() > broot.components().count() }) .unwrap_or(true); if better { best = Some((name.as_str(), p)); } } } best } /// Default agent of the project containing cwd (issue #30 helper). pub fn default_agent_for_dir(app: &App, cwd: &Path) -> Option { profile_for(app, cwd) .and_then(|(_, p)| p.default_agent.clone()) } #[cfg(test)] mod tests { use super::*; use clap::Parser; fn app_with_events(lines: &[&str]) -> App { let guard = tempfile::tempdir().unwrap(); let dir = guard.path().to_path_buf(); std::mem::forget(guard); let cfg = dir.join("config.yaml"); std::fs::write( &cfg, concat!( "version: \"1.0\"\n", "settings:\n", " auto_install_deps: false\n", "agents: []\n", ), ) .unwrap(); let cli = crate::cli::Cli::parse_from(["am", "--config", cfg.to_str().unwrap()]); let mut app = App::from_cli(cli).unwrap(); let mut p = app.paths.clone(); p.install_dir = dir.join("agents"); p.bin_dir = dir.join("agents").join("bin"); p.log_dir = dir.join("logs"); p.state_file = dir.join("state.json"); p.probe_cache_file = dir.join("probe-cache.json"); p.config_dir = Some(dir.join("config")); app.paths = p; let month = chrono::Utc::now().format("%Y%m"); let path = dir.join(format!("events-{month}.jsonl")); let mut text = String::new(); for l in lines { text.push_str(l); text.push('\n'); } std::fs::create_dir_all(&dir).unwrap(); std::fs::write(&path, text).unwrap(); app } #[test] fn aggregates_projects_from_events() { let app = app_with_events(&[ r#"{"ts":"2026-08-01T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s1","duration_s":10,"exit_code":0}"#, r#"{"ts":"2026-08-02T10:00:00Z","kind":"run","agent":"claude-code","project":"/home/x/api","session":"s2","duration_s":20,"exit_code":0}"#, r#"{"ts":"2026-08-03T10:00:00Z","kind":"run","agent":"aider","project":"/home/x/api","session":"s3","duration_s":5,"exit_code":0}"#, r#"{"ts":"2026-08-04T10:00:00Z","kind":"run","agent":"smelt","project":"/home/x/rusty","session":"s4","duration_s":7,"exit_code":0}"#, ]); let out = aggregate(&app); assert_eq!(out.len(), 2); let api = out.iter().find(|s| s.root == "/home/x/api").unwrap(); assert_eq!(api.agents, vec!["aider".to_string(), "claude-code".to_string()]); assert_eq!(api.dominant.as_deref(), Some("claude-code")); assert_eq!(api.sessions, 3); assert_eq!(api.launches, 3); assert_eq!(api.total_duration_s, 35); assert!(api.last_activity.is_some()); assert!(cache_file(&app).exists(), "cache written"); } #[test] fn profile_matching_prefers_longest_root() { let app = app_with_events(&[]); let tmp = std::env::temp_dir(); let api = tmp.join("p1-api"); let sub = api.join("src"); let _ = (api, sub); // Ce test est complete dans l'integration (config avec profils) ; // ici on verifie juste l'absence de profil par defaut. assert!(default_agent_for_dir(&app, &std::env::temp_dir()).is_none()); } }