1use std::collections::{BTreeMap, HashSet};
2use std::path::PathBuf;
3use std::process::{Command, Stdio};
4use std::time::Duration;
5
6use wait_timeout::ChildExt;
7
8use crate::harness::registry::{self, HarnessId};
9use crate::models::probes::ProbeRefreshMode;
10use crate::models::probes::cursor_cache::CachedCursorProbeOutcome;
11use crate::models::probes::opencode_cache::CachedProbeOutcome;
12use crate::models::probes::pi_cache::CachedPiProbeOutcome;
13use crate::models::probes::{CursorProbeResult, OpenCodeProbeResult, PiProbeResult};
14
15#[derive(Debug, Clone)]
16pub struct CapabilityCollectionOptions {
17 pub offline: bool,
19 pub probe_refresh: ProbeRefreshMode,
20}
21
22impl Default for CapabilityCollectionOptions {
23 fn default() -> Self {
24 Self {
25 offline: false,
26 probe_refresh: ProbeRefreshMode::Background,
27 }
28 }
29}
30
31#[derive(Debug, Clone)]
32pub struct CapabilitySnapshot {
33 pub executable: BTreeMap<HarnessId, ExecutableState>,
34 pub auth: BTreeMap<HarnessId, AuthState>,
35 pub opencode: CachedProbeOutcome,
36 pub pi: CachedPiProbeOutcome,
37 pub cursor: CachedCursorProbeOutcome,
38 pub offline: bool,
39}
40
41impl CapabilitySnapshot {
42 pub fn installed_harnesses(&self) -> HashSet<String> {
43 self.executable
44 .iter()
45 .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
46 .map(|(id, _)| id)
47 .map(|id| id.as_str().to_string())
48 .collect()
49 }
50}
51
52#[derive(Debug, Clone)]
57pub struct CapabilitySession {
58 executable: BTreeMap<HarnessId, ExecutableState>,
59 auth: BTreeMap<HarnessId, AuthState>,
60 installed: HashSet<String>,
61 offline: bool,
62 probe_refresh: ProbeRefreshMode,
63 opencode: Option<CachedProbeOutcome>,
64 pi: Option<CachedPiProbeOutcome>,
65 cursor: Option<CachedCursorProbeOutcome>,
66}
67
68impl CapabilitySession {
69 pub fn collect(options: &CapabilityCollectionOptions) -> Self {
70 Self::collect_with_resolver(options, &PathExecutableResolver)
71 }
72
73 pub fn collect_with_resolver(
74 options: &CapabilityCollectionOptions,
75 resolver: &dyn ExecutableResolver,
76 ) -> Self {
77 let mut executable = BTreeMap::new();
78 let mut auth = BTreeMap::new();
79
80 for descriptor in registry::descriptors() {
81 let state = resolver.resolve(descriptor.binary);
82 executable.insert(descriptor.id, state.clone());
83 auth.insert(
84 descriptor.id,
85 native_auth_state(descriptor.id, &state, resolver, auth_probe_timeout()),
86 );
87 }
88
89 let installed = executable
90 .iter()
91 .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
92 .map(|(id, _)| id.as_str().to_string())
93 .collect::<HashSet<_>>();
94
95 Self {
96 executable,
97 auth,
98 installed,
99 offline: options.offline,
100 probe_refresh: options.probe_refresh,
101 opencode: None,
102 pi: None,
103 cursor: None,
104 }
105 }
106
107 pub fn installed_harnesses(&self) -> HashSet<String> {
108 self.installed.clone()
109 }
110
111 pub fn offline(&self) -> bool {
112 self.offline
113 }
114
115 pub fn executable_snapshot(&self) -> BTreeMap<HarnessId, ExecutableState> {
116 self.executable.clone()
117 }
118
119 pub fn auth_snapshot(&self) -> BTreeMap<HarnessId, AuthState> {
120 self.auth.clone()
121 }
122
123 pub fn opencode_outcome(&mut self) -> &CachedProbeOutcome {
124 self.opencode.get_or_insert_with(|| {
125 cached_opencode_outcome(&self.installed, self.offline, self.probe_refresh)
126 })
127 }
128
129 pub fn loaded_opencode_outcome(&self) -> Option<&CachedProbeOutcome> {
130 self.opencode.as_ref()
131 }
132
133 pub fn loaded_pi_outcome(&self) -> Option<&CachedPiProbeOutcome> {
134 self.pi.as_ref()
135 }
136
137 pub fn loaded_cursor_outcome(&self) -> Option<&CachedCursorProbeOutcome> {
138 self.cursor.as_ref()
139 }
140
141 pub fn loaded_opencode_probe_result(&self) -> Option<&OpenCodeProbeResult> {
142 self.loaded_opencode_outcome()
143 .and_then(CachedProbeOutcome::result)
144 }
145
146 pub fn loaded_pi_probe_result(&self) -> Option<&PiProbeResult> {
147 self.loaded_pi_outcome()
148 .and_then(CachedPiProbeOutcome::result)
149 }
150
151 pub fn loaded_cursor_probe_result(&self) -> Option<&CursorProbeResult> {
152 self.loaded_cursor_outcome()
153 .and_then(CachedCursorProbeOutcome::result)
154 }
155
156 pub fn pi_outcome(&mut self) -> &CachedPiProbeOutcome {
157 self.pi.get_or_insert_with(|| {
158 cached_pi_outcome(&self.installed, self.offline, self.probe_refresh)
159 })
160 }
161
162 pub fn cursor_outcome(&mut self) -> &CachedCursorProbeOutcome {
163 self.cursor.get_or_insert_with(|| {
164 cached_cursor_outcome(&self.installed, self.offline, self.probe_refresh)
165 })
166 }
167
168 pub fn opencode_probe_result(&mut self) -> Option<OpenCodeProbeResult> {
169 self.opencode_outcome().result().cloned()
170 }
171
172 pub fn pi_probe_result(&mut self) -> Option<PiProbeResult> {
173 self.pi_outcome().result().cloned()
174 }
175
176 pub fn cursor_probe_result(&mut self) -> Option<CursorProbeResult> {
177 self.cursor_outcome().result().cloned()
178 }
179
180 pub fn into_snapshot(mut self) -> CapabilitySnapshot {
181 let opencode = self.opencode.take().unwrap_or_else(|| {
182 cached_opencode_outcome(&self.installed, self.offline, self.probe_refresh)
183 });
184 let pi = self.pi.take().unwrap_or_else(|| {
185 cached_pi_outcome(&self.installed, self.offline, self.probe_refresh)
186 });
187 let cursor = self.cursor.take().unwrap_or_else(|| {
188 cached_cursor_outcome(&self.installed, self.offline, self.probe_refresh)
189 });
190
191 CapabilitySnapshot {
192 executable: self.executable,
193 auth: self.auth,
194 opencode,
195 pi,
196 cursor,
197 offline: self.offline,
198 }
199 }
200}
201
202fn cached_opencode_outcome(
203 installed: &HashSet<String>,
204 is_offline: bool,
205 probe_refresh: ProbeRefreshMode,
206) -> CachedProbeOutcome {
207 crate::models::probes::opencode_cache::probe_cached(installed, is_offline, probe_refresh)
208}
209
210fn cached_pi_outcome(
211 installed: &HashSet<String>,
212 is_offline: bool,
213 probe_refresh: ProbeRefreshMode,
214) -> CachedPiProbeOutcome {
215 crate::models::probes::pi_cache::probe_cached(installed, is_offline, probe_refresh)
216}
217
218fn cached_cursor_outcome(
219 installed: &HashSet<String>,
220 is_offline: bool,
221 probe_refresh: ProbeRefreshMode,
222) -> CachedCursorProbeOutcome {
223 crate::models::probes::cursor_cache::probe_cached(installed, is_offline, probe_refresh)
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum ExecutableState {
228 Found { path: PathBuf },
229 Missing,
230}
231
232#[derive(Debug, Clone, PartialEq, Eq)]
233pub enum AuthState {
234 NotApplicable,
235 Authenticated,
236 Unauthenticated,
237 Unknown { reason: String },
238}
239
240pub trait ExecutableResolver {
241 fn resolve(&self, binary: &str) -> ExecutableState;
242}
243
244#[derive(Debug, Default, Clone, Copy)]
245pub struct PathExecutableResolver;
246
247impl ExecutableResolver for PathExecutableResolver {
248 fn resolve(&self, binary: &str) -> ExecutableState {
249 if let Ok(path) = which::which(binary) {
250 return ExecutableState::Found { path };
251 }
252
253 #[cfg(windows)]
254 {
255 for ext in ["exe", "cmd", "bat"] {
256 if let Ok(path) = which::which(format!("{binary}.{ext}")) {
257 return ExecutableState::Found { path };
258 }
259 }
260 }
261
262 ExecutableState::Missing
263 }
264}
265
266pub fn collect_capability_snapshot(options: &CapabilityCollectionOptions) -> CapabilitySnapshot {
267 collect_capability_snapshot_with_resolver(options, &PathExecutableResolver)
268}
269
270pub fn collect_capability_snapshot_with_resolver(
271 options: &CapabilityCollectionOptions,
272 resolver: &dyn ExecutableResolver,
273) -> CapabilitySnapshot {
274 CapabilitySession::collect_with_resolver(options, resolver).into_snapshot()
275}
276
277pub fn native_harness_authenticated(harness: &str) -> bool {
278 native_auth_state_for_name(harness) == AuthState::Authenticated
279}
280
281pub fn native_auth_state_for_name(harness: &str) -> AuthState {
282 let Some(id) = registry::parse(harness) else {
283 return AuthState::Unknown {
284 reason: "unknown harness".to_string(),
285 };
286 };
287
288 let resolver = PathExecutableResolver;
289 let state = resolver.resolve(registry::descriptor(id).binary);
290 native_auth_state(id, &state, &resolver, auth_probe_timeout())
291}
292
293fn native_auth_state(
294 id: HarnessId,
295 executable: &ExecutableState,
296 resolver: &dyn ExecutableResolver,
297 timeout: Duration,
298) -> AuthState {
299 let (binary, args) = match id {
300 HarnessId::Codex => ("codex", &["login", "status"][..]),
301 HarnessId::Claude => ("claude", &["auth", "status"][..]),
302 _ => return AuthState::NotApplicable,
303 };
304
305 if !matches!(executable, ExecutableState::Found { .. }) {
306 return AuthState::Unauthenticated;
307 }
308
309 run_status_command(binary, args, timeout, resolver)
310}
311
312pub fn auth_probe_timeout() -> Duration {
313 std::env::var("MARS_NATIVE_HARNESS_AUTH_TIMEOUT_SECS")
314 .ok()
315 .and_then(|value| value.parse::<u64>().ok())
316 .map(Duration::from_secs)
317 .unwrap_or(Duration::from_secs(2))
318}
319
320fn run_status_command(
321 command: &str,
322 args: &[&str],
323 timeout: Duration,
324 resolver: &dyn ExecutableResolver,
325) -> AuthState {
326 let program = resolve_binary_path(command, resolver).unwrap_or_else(|| PathBuf::from(command));
327
328 let mut child = match Command::new(program)
329 .args(args)
330 .stdin(Stdio::null())
331 .stdout(Stdio::null())
332 .stderr(Stdio::null())
333 .spawn()
334 {
335 Ok(child) => child,
336 Err(error) => {
337 return AuthState::Unknown {
338 reason: format!("spawn failed: {error}"),
339 };
340 }
341 };
342
343 match child.wait_timeout(timeout) {
344 Ok(Some(status)) if status.success() => AuthState::Authenticated,
345 Ok(Some(_)) => AuthState::Unauthenticated,
346 Ok(None) => {
347 let _ = child.kill();
348 let _ = child.wait();
349 AuthState::Unknown {
350 reason: "auth probe timeout".to_string(),
351 }
352 }
353 Err(error) => AuthState::Unknown {
354 reason: format!("auth probe wait failed: {error}"),
355 },
356 }
357}
358
359pub fn resolve_binary_path(binary: &str, resolver: &dyn ExecutableResolver) -> Option<PathBuf> {
360 match resolver.resolve(binary) {
361 ExecutableState::Found { path } => Some(path),
362 ExecutableState::Missing => None,
363 }
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369 use std::collections::HashMap;
370
371 #[derive(Default)]
372 struct FakeResolver {
373 map: HashMap<String, ExecutableState>,
374 }
375
376 impl ExecutableResolver for FakeResolver {
377 fn resolve(&self, binary: &str) -> ExecutableState {
378 self.map
379 .get(binary)
380 .cloned()
381 .unwrap_or(ExecutableState::Missing)
382 }
383 }
384
385 #[test]
386 fn snapshot_marks_installed_harnesses_from_resolver() {
387 let mut resolver = FakeResolver::default();
388 resolver.map.insert(
389 "pi".to_string(),
390 ExecutableState::Found {
391 path: PathBuf::from("/tmp/pi"),
392 },
393 );
394
395 let options = CapabilityCollectionOptions {
396 offline: true,
397 probe_refresh: ProbeRefreshMode::Skip,
398 };
399 let snapshot = collect_capability_snapshot_with_resolver(&options, &resolver);
400
401 let installed = snapshot.installed_harnesses();
402 assert!(installed.contains("pi"));
403 assert!(!installed.contains("codex"));
404 }
405
406 #[test]
407 fn native_auth_for_non_native_harness_is_not_applicable() {
408 let resolver = FakeResolver::default();
409 let state = native_auth_state(
410 HarnessId::Pi,
411 &ExecutableState::Found {
412 path: PathBuf::from("/tmp/pi"),
413 },
414 &resolver,
415 Duration::from_secs(1),
416 );
417
418 assert_eq!(state, AuthState::NotApplicable);
419 }
420
421 #[test]
422 fn resolve_binary_path_returns_none_when_missing() {
423 let resolver = FakeResolver::default();
424 assert_eq!(resolve_binary_path("codex", &resolver), None);
425 }
426
427 #[test]
428 fn probe_refresh_skip_does_not_force_offline_mode() {
429 let options = CapabilityCollectionOptions {
430 offline: false,
431 probe_refresh: ProbeRefreshMode::Skip,
432 };
433 let session = CapabilitySession::collect_with_resolver(&options, &FakeResolver::default());
434 assert!(!session.offline());
435 }
436}