devboy_core/agents/
copilot.rs1use 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}