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::opencode_cache::{self, CachedProbeOutcome};
10use crate::models::probes::pi_cache::{self, CachedPiProbeOutcome};
11
12#[derive(Debug, Clone)]
13pub struct CapabilityCollectionOptions {
14 pub offline: bool,
15 pub allow_probe_refresh: bool,
16}
17
18impl Default for CapabilityCollectionOptions {
19 fn default() -> Self {
20 Self {
21 offline: false,
22 allow_probe_refresh: true,
23 }
24 }
25}
26
27#[derive(Debug, Clone)]
28pub struct CapabilitySnapshot {
29 pub executable: BTreeMap<HarnessId, ExecutableState>,
30 pub auth: BTreeMap<HarnessId, AuthState>,
31 pub opencode: CachedProbeOutcome,
32 pub pi: CachedPiProbeOutcome,
33 pub offline: bool,
34}
35
36impl CapabilitySnapshot {
37 pub fn installed_harnesses(&self) -> HashSet<String> {
38 self.executable
39 .iter()
40 .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
41 .map(|(id, _)| id)
42 .map(|id| id.as_str().to_string())
43 .collect()
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ExecutableState {
49 Found { path: PathBuf },
50 Missing,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum AuthState {
55 NotApplicable,
56 Authenticated,
57 Unauthenticated,
58 Unknown { reason: String },
59}
60
61pub trait ExecutableResolver {
62 fn resolve(&self, binary: &str) -> ExecutableState;
63}
64
65#[derive(Debug, Default, Clone, Copy)]
66pub struct PathExecutableResolver;
67
68impl ExecutableResolver for PathExecutableResolver {
69 fn resolve(&self, binary: &str) -> ExecutableState {
70 if let Ok(path) = which::which(binary) {
71 return ExecutableState::Found { path };
72 }
73
74 #[cfg(windows)]
75 {
76 for ext in ["exe", "cmd", "bat"] {
77 if let Ok(path) = which::which(format!("{binary}.{ext}")) {
78 return ExecutableState::Found { path };
79 }
80 }
81 }
82
83 ExecutableState::Missing
84 }
85}
86
87pub fn collect_capability_snapshot(options: &CapabilityCollectionOptions) -> CapabilitySnapshot {
88 collect_capability_snapshot_with_resolver(options, &PathExecutableResolver)
89}
90
91pub fn collect_capability_snapshot_with_resolver(
92 options: &CapabilityCollectionOptions,
93 resolver: &dyn ExecutableResolver,
94) -> CapabilitySnapshot {
95 let mut executable = BTreeMap::new();
96 let mut auth = BTreeMap::new();
97
98 for descriptor in registry::descriptors() {
99 let state = resolver.resolve(descriptor.binary);
100 executable.insert(descriptor.id, state.clone());
101 auth.insert(
102 descriptor.id,
103 native_auth_state(descriptor.id, &state, resolver, auth_probe_timeout()),
104 );
105 }
106
107 let installed = executable
108 .iter()
109 .filter(|(_, state)| matches!(state, ExecutableState::Found { .. }))
110 .map(|(id, _)| id)
111 .map(|id| id.as_str().to_string())
112 .collect::<HashSet<_>>();
113
114 let opencode_offline = options.offline || !options.allow_probe_refresh;
115 let pi_offline = options.offline || !options.allow_probe_refresh;
116
117 CapabilitySnapshot {
118 executable,
119 auth,
120 opencode: opencode_cache::probe_cached(&installed, opencode_offline),
121 pi: pi_cache::probe_cached(&installed, pi_offline),
122 offline: options.offline,
123 }
124}
125
126pub fn native_harness_authenticated(harness: &str) -> bool {
127 native_auth_state_for_name(harness) == AuthState::Authenticated
128}
129
130pub fn native_auth_state_for_name(harness: &str) -> AuthState {
131 let Some(id) = registry::parse(harness) else {
132 return AuthState::Unknown {
133 reason: "unknown harness".to_string(),
134 };
135 };
136
137 let resolver = PathExecutableResolver;
138 let state = resolver.resolve(registry::descriptor(id).binary);
139 native_auth_state(id, &state, &resolver, auth_probe_timeout())
140}
141
142fn native_auth_state(
143 id: HarnessId,
144 executable: &ExecutableState,
145 resolver: &dyn ExecutableResolver,
146 timeout: Duration,
147) -> AuthState {
148 let (binary, args) = match id {
149 HarnessId::Codex => ("codex", &["login", "status"][..]),
150 HarnessId::Claude => ("claude", &["auth", "status"][..]),
151 _ => return AuthState::NotApplicable,
152 };
153
154 if !matches!(executable, ExecutableState::Found { .. }) {
155 return AuthState::Unauthenticated;
156 }
157
158 run_status_command(binary, args, timeout, resolver)
159}
160
161pub fn auth_probe_timeout() -> Duration {
162 std::env::var("MARS_NATIVE_HARNESS_AUTH_TIMEOUT_SECS")
163 .ok()
164 .and_then(|value| value.parse::<u64>().ok())
165 .map(Duration::from_secs)
166 .unwrap_or(Duration::from_secs(2))
167}
168
169fn run_status_command(
170 command: &str,
171 args: &[&str],
172 timeout: Duration,
173 resolver: &dyn ExecutableResolver,
174) -> AuthState {
175 let program = resolve_binary_path(command, resolver).unwrap_or_else(|| PathBuf::from(command));
176
177 let mut child = match Command::new(program)
178 .args(args)
179 .stdin(Stdio::null())
180 .stdout(Stdio::null())
181 .stderr(Stdio::null())
182 .spawn()
183 {
184 Ok(child) => child,
185 Err(error) => {
186 return AuthState::Unknown {
187 reason: format!("spawn failed: {error}"),
188 };
189 }
190 };
191
192 match child.wait_timeout(timeout) {
193 Ok(Some(status)) if status.success() => AuthState::Authenticated,
194 Ok(Some(_)) => AuthState::Unauthenticated,
195 Ok(None) => {
196 let _ = child.kill();
197 let _ = child.wait();
198 AuthState::Unknown {
199 reason: "auth probe timeout".to_string(),
200 }
201 }
202 Err(error) => AuthState::Unknown {
203 reason: format!("auth probe wait failed: {error}"),
204 },
205 }
206}
207
208pub fn resolve_binary_path(binary: &str, resolver: &dyn ExecutableResolver) -> Option<PathBuf> {
209 match resolver.resolve(binary) {
210 ExecutableState::Found { path } => Some(path),
211 ExecutableState::Missing => None,
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use std::collections::HashMap;
219
220 #[derive(Default)]
221 struct FakeResolver {
222 map: HashMap<String, ExecutableState>,
223 }
224
225 impl ExecutableResolver for FakeResolver {
226 fn resolve(&self, binary: &str) -> ExecutableState {
227 self.map
228 .get(binary)
229 .cloned()
230 .unwrap_or(ExecutableState::Missing)
231 }
232 }
233
234 #[test]
235 fn snapshot_marks_installed_harnesses_from_resolver() {
236 let mut resolver = FakeResolver::default();
237 resolver.map.insert(
238 "pi".to_string(),
239 ExecutableState::Found {
240 path: PathBuf::from("/tmp/pi"),
241 },
242 );
243
244 let options = CapabilityCollectionOptions {
245 offline: true,
246 allow_probe_refresh: false,
247 };
248 let snapshot = collect_capability_snapshot_with_resolver(&options, &resolver);
249
250 let installed = snapshot.installed_harnesses();
251 assert!(installed.contains("pi"));
252 assert!(!installed.contains("codex"));
253 }
254
255 #[test]
256 fn native_auth_for_non_native_harness_is_not_applicable() {
257 let resolver = FakeResolver::default();
258 let state = native_auth_state(
259 HarnessId::Pi,
260 &ExecutableState::Found {
261 path: PathBuf::from("/tmp/pi"),
262 },
263 &resolver,
264 Duration::from_secs(1),
265 );
266
267 assert_eq!(state, AuthState::NotApplicable);
268 }
269
270 #[test]
271 fn resolve_binary_path_returns_none_when_missing() {
272 let resolver = FakeResolver::default();
273 assert_eq!(resolve_binary_path("codex", &resolver), None);
274 }
275}