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