Skip to main content

devboy_core/agents/
registry.rs

1//! Registry that runs every per-agent detector and returns sorted snapshots.
2//!
3//! `detect_all` reads `dirs::home_dir()` and is the public entrypoint.
4//! `detect_all_with_home` is for tests / users who want to point at a
5//! sandboxed home (e.g. tempdir fixtures).
6
7use std::path::Path;
8
9use chrono::Utc;
10
11use super::{AgentDetector, AgentSnapshot};
12
13fn detectors() -> Vec<Box<dyn AgentDetector>> {
14    vec![
15        Box::new(super::claude::ClaudeDetector),
16        Box::new(super::copilot::CopilotDetector),
17        Box::new(super::codex::CodexDetector),
18        Box::new(super::kimi::KimiDetector),
19        Box::new(super::cursor::CursorDetector),
20        Box::new(super::gemini::GeminiDetector),
21        Box::new(super::antigravity::AntigravityDetector),
22    ]
23}
24
25/// Run every detector against the user's real home dir. Returns snapshots
26/// sorted by score (descending). Empty home → empty result.
27pub fn detect_all() -> Vec<AgentSnapshot> {
28    match dirs::home_dir() {
29        Some(home) => detect_all_with_home(&home),
30        None => Vec::new(),
31    }
32}
33
34/// Run every detector against the given home dir. Used by tests with
35/// synthetic fixtures.
36pub fn detect_all_with_home(home: &Path) -> Vec<AgentSnapshot> {
37    let now = Utc::now();
38    let mut out: Vec<AgentSnapshot> = detectors()
39        .into_iter()
40        .map(|d| {
41            let mut snap = d.detect(home);
42            snap.score = super::score::compute_score(snap.last_used, snap.sessions, now);
43            snap
44        })
45        .collect();
46    out.sort_by(|a, b| {
47        b.score
48            .partial_cmp(&a.score)
49            .unwrap_or(std::cmp::Ordering::Equal)
50    });
51    out
52}
53
54#[cfg(test)]
55mod tests {
56    use super::*;
57    use crate::agents::InstallStatus;
58    use std::fs;
59    use tempfile::tempdir;
60
61    #[test]
62    fn returns_one_snapshot_per_known_detector() {
63        let home = tempdir().unwrap();
64        let snaps = detect_all_with_home(home.path());
65        // Seven detectors: claude, copilot, codex, kimi, cursor, gemini, antigravity.
66        assert_eq!(snaps.len(), 7);
67        let ids: std::collections::HashSet<_> = snaps.iter().map(|s| s.id).collect();
68        for expected in [
69            "claude",
70            "copilot",
71            "codex",
72            "kimi",
73            "cursor",
74            "gemini",
75            "antigravity",
76        ] {
77            assert!(ids.contains(expected), "missing detector for {expected}");
78        }
79    }
80
81    #[test]
82    fn scores_are_clamped_in_unit_interval() {
83        let home = tempdir().unwrap();
84        let snaps = detect_all_with_home(home.path());
85        for s in &snaps {
86            assert!(
87                (0.0..=1.0).contains(&s.score),
88                "score out of range for {}: {}",
89                s.id,
90                s.score
91            );
92        }
93    }
94
95    #[test]
96    fn detect_all_runs_against_real_home_without_panicking() {
97        // Smoke: detect_all() reads `dirs::home_dir()`. On a CI runner
98        // that has no home (very unusual) it returns empty; on a normal
99        // machine it returns the seven detector snapshots. Either is fine.
100        let snaps = detect_all();
101        assert!(snaps.is_empty() || snaps.len() == 7);
102    }
103
104    #[test]
105    fn snapshots_sorted_by_score_descending() {
106        // Build a synthetic Claude install — many sessions, recent mtime.
107        // Should rank highest among the seven on this synthetic home.
108        let home = tempdir().unwrap();
109        let projects = home.path().join(".claude/projects/-Users-x-foo");
110        fs::create_dir_all(&projects).unwrap();
111        for i in 0..50 {
112            fs::write(projects.join(format!("session-{i}.jsonl")), b"{}\n").unwrap();
113        }
114
115        let snaps = detect_all_with_home(home.path());
116        for window in snaps.windows(2) {
117            assert!(
118                window[0].score >= window[1].score,
119                "not sorted: {} ({}) before {} ({})",
120                window[0].id,
121                window[0].score,
122                window[1].id,
123                window[1].score,
124            );
125        }
126        assert_eq!(snaps[0].id, "claude");
127        assert_eq!(snaps[0].status, InstallStatus::Yes);
128        assert_eq!(snaps[0].sessions, Some(50));
129    }
130}