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