Skip to main content

devboy_core/agents/
copilot.rs

1//! GitHub Copilot CLI detector.
2//!
3//! Two formats are supported:
4//! - **New** (≥1.0, apr 2026+): `~/.copilot/session-state/<uuid>/events.jsonl`
5//! - **Old** (<1.0): `~/.copilot/session-state/<uuid>.jsonl`
6//!
7//! Sessions = count of (new dirs with events.jsonl) + (old jsonl files).
8//! last_used = max mtime across both.
9
10use std::path::Path;
11
12use super::fs_util::to_utc;
13use super::{AgentDetector, AgentSnapshot, InstallStatus};
14
15const ID: &str = "copilot";
16const DISPLAY_NAME: &str = "GitHub Copilot CLI";
17const MAX_ENTRIES: usize = 5_000;
18
19pub struct CopilotDetector;
20
21impl AgentDetector for CopilotDetector {
22    fn id(&self) -> &'static str {
23        ID
24    }
25    fn display_name(&self) -> &'static str {
26        DISPLAY_NAME
27    }
28
29    fn detect(&self, home: &Path) -> AgentSnapshot {
30        let copilot_dir = home.join(".copilot");
31        let session_state = copilot_dir.join("session-state");
32        let paths_checked = vec![copilot_dir.clone(), session_state.clone()];
33
34        let dir_present = copilot_dir.is_dir();
35        let binary_present = which::which("copilot").is_ok();
36
37        let status = if dir_present || binary_present {
38            InstallStatus::Yes
39        } else {
40            InstallStatus::No
41        };
42        if status == InstallStatus::No {
43            return empty(paths_checked);
44        }
45
46        let (sessions, last_used) = walk_session_state(&session_state);
47
48        AgentSnapshot {
49            id: ID,
50            display_name: DISPLAY_NAME,
51            status,
52            sessions: (sessions > 0).then_some(sessions),
53            last_used,
54            score: 0.0,
55            paths_checked,
56        }
57    }
58}
59
60fn walk_session_state(root: &Path) -> (u64, Option<chrono::DateTime<chrono::Utc>>) {
61    let Ok(entries) = std::fs::read_dir(root) else {
62        return (0, None);
63    };
64    let mut count = 0u64;
65    let mut best: Option<chrono::DateTime<chrono::Utc>> = None;
66    for entry in entries.flatten().take(MAX_ENTRIES) {
67        let path = entry.path();
68        let Ok(file_type) = entry.file_type() else {
69            continue;
70        };
71        let candidate_mtime = if file_type.is_dir() {
72            let events = path.join("events.jsonl");
73            if events.exists() {
74                count += 1;
75                events
76                    .metadata()
77                    .ok()
78                    .and_then(|m| m.modified().ok())
79                    .and_then(to_utc)
80            } else {
81                None
82            }
83        } else if path.extension().is_some_and(|e| e == "jsonl") {
84            count += 1;
85            entry
86                .metadata()
87                .ok()
88                .and_then(|m| m.modified().ok())
89                .and_then(to_utc)
90        } else {
91            None
92        };
93        if let Some(t) = candidate_mtime {
94            best = Some(best.map_or(t, |b| b.max(t)));
95        }
96    }
97    (count, best)
98}
99
100fn empty(paths_checked: Vec<std::path::PathBuf>) -> AgentSnapshot {
101    AgentSnapshot {
102        id: ID,
103        display_name: DISPLAY_NAME,
104        status: InstallStatus::No,
105        sessions: None,
106        last_used: None,
107        score: 0.0,
108        paths_checked,
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs;
116    use tempfile::tempdir;
117
118    #[test]
119    fn detects_mixed_old_and_new_format() {
120        let home = tempdir().unwrap();
121        let state = home.path().join(".copilot/session-state");
122        fs::create_dir_all(&state).unwrap();
123        fs::create_dir_all(state.join("aaa-new1")).unwrap();
124        fs::write(state.join("aaa-new1/events.jsonl"), b"{}\n").unwrap();
125        fs::create_dir_all(state.join("bbb-new2")).unwrap();
126        fs::write(state.join("bbb-new2/events.jsonl"), b"{}\n").unwrap();
127        fs::write(state.join("ccc-old1.jsonl"), b"{}\n").unwrap();
128
129        let snap = CopilotDetector.detect(home.path());
130        assert_eq!(snap.status, InstallStatus::Yes);
131        assert_eq!(snap.sessions, Some(3));
132    }
133
134    #[test]
135    fn no_copilot_dir_means_not_installed() {
136        let home = tempdir().unwrap();
137        let snap = CopilotDetector.detect(home.path());
138        if which::which("copilot").is_err() {
139            assert_eq!(snap.status, InstallStatus::No);
140            assert!(snap.sessions.is_none());
141        }
142    }
143
144    #[test]
145    fn empty_session_state_dir_yields_no_sessions() {
146        let home = tempdir().unwrap();
147        fs::create_dir_all(home.path().join(".copilot/session-state")).unwrap();
148        let snap = CopilotDetector.detect(home.path());
149        assert_eq!(snap.status, InstallStatus::Yes);
150        assert!(snap.sessions.is_none());
151        assert!(snap.last_used.is_none());
152    }
153
154    #[test]
155    fn ignores_random_non_jsonl_files_and_orphan_dirs() {
156        let home = tempdir().unwrap();
157        let state = home.path().join(".copilot/session-state");
158        fs::create_dir_all(&state).unwrap();
159        fs::write(state.join("readme.txt"), b"hello").unwrap();
160        fs::create_dir_all(state.join("orphan-dir")).unwrap();
161        fs::write(state.join("real.jsonl"), b"{}\n").unwrap();
162
163        let snap = CopilotDetector.detect(home.path());
164        assert_eq!(snap.sessions, Some(1));
165    }
166
167    #[test]
168    fn copilot_dir_alone_without_session_state_still_reports_install() {
169        let home = tempdir().unwrap();
170        fs::create_dir_all(home.path().join(".copilot")).unwrap();
171        let snap = CopilotDetector.detect(home.path());
172        assert_eq!(snap.status, InstallStatus::Yes);
173        assert!(snap.sessions.is_none());
174    }
175}