use std::collections::HashMap;
use std::io::Read;
use std::process::{Command, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use wait_timeout::ChildExt;
#[derive(Debug, Clone, Default)]
pub struct OpenCodeProbeResult {
pub providers: HashMap<String, bool>,
pub model_slugs: Vec<String>,
pub provider_probe_success: bool,
pub model_probe_success: bool,
pub error: Option<String>,
}
const DEFAULT_PROBE_TIMEOUT_SECS: u64 = 5;
pub fn probe() -> OpenCodeProbeResult {
probe_with_timeout(probe_timeout())
}
pub fn probe_with_timeout(timeout: Duration) -> OpenCodeProbeResult {
let deadline = Instant::now() + timeout;
let mut result = OpenCodeProbeResult::default();
match run_command("opencode", &["providers", "list"], timeout) {
Ok(stdout) => {
result.providers = parse_providers_output(&stdout);
result.provider_probe_success = true;
}
Err(error) => {
result.error = Some(format!("provider probe failed: {error}"));
result.provider_probe_success = false;
}
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
result.model_probe_success = false;
if result.error.is_none() {
result.error = Some("timeout before model probe".to_string());
}
return result;
}
match run_command("opencode", &["models"], remaining) {
Ok(stdout) => {
result.model_slugs = parse_models_output(&stdout);
result.model_probe_success = true;
}
Err(error) => {
result.model_probe_success = false;
if result.error.is_none() {
result.error = Some(format!("model probe failed: {error}"));
}
}
}
result
}
fn probe_timeout() -> Duration {
std::env::var("MARS_PROBE_TIMEOUT_SECS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(DEFAULT_PROBE_TIMEOUT_SECS))
}
fn run_command(cmd: &str, args: &[&str], timeout: Duration) -> Result<String, String> {
let mut child = Command::new(cmd)
.args(args)
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|error| format!("spawn failed: {error}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "stdout capture unavailable".to_string())?;
let stdout_reader = thread::spawn(move || {
let mut stdout = stdout;
let mut output = Vec::new();
stdout
.read_to_end(&mut output)
.map(|_| output)
.map_err(|error| format!("stdout read failed: {error}"))
});
match child
.wait_timeout(timeout)
.map_err(|error| format!("wait failed: {error}"))?
{
Some(status) if status.success() => {
let stdout = stdout_reader
.join()
.map_err(|_| "stdout reader panicked".to_string())??;
String::from_utf8(stdout).map_err(|error| format!("invalid utf8: {error}"))
}
Some(status) => {
let _ = stdout_reader.join();
Err(format!("exit code {}", status.code().unwrap_or(-1)))
}
None => {
let _ = child.kill();
let _ = child.wait();
let _ = stdout_reader.join();
Err("timeout".to_string())
}
}
}
fn strip_ansi(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
while let Some(&next) = chars.peek() {
chars.next();
if next.is_ascii_alphabetic() {
break;
}
}
} else {
result.push(ch);
}
}
result
}
fn parse_providers_output(stdout: &str) -> HashMap<String, bool> {
let mut providers = HashMap::new();
for line in stdout.lines() {
let clean = strip_ansi(line.trim());
if let Some(rest) = clean.strip_prefix('●').or_else(|| clean.strip_prefix('*')) {
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() >= 2 {
providers.insert(parts[0].to_lowercase(), true);
}
}
}
providers
}
fn parse_models_output(stdout: &str) -> Vec<String> {
stdout
.lines()
.map(|line| strip_ansi(line.trim()))
.filter(|line| !line.is_empty() && line.contains('/'))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_ansi_basic() {
let input = "\x1b[32mGreen\x1b[0m";
assert_eq!(strip_ansi(input), "Green");
}
#[test]
fn test_strip_ansi_no_escapes() {
assert_eq!(strip_ansi("Plain text"), "Plain text");
}
#[test]
fn test_parse_providers_bullet() {
let output = r#"┌ Credentials [path redacted]
│
● OpenAI oauth
│
● Google api
│
● OpenRouter api
│
└ 3 credentials"#;
let providers = parse_providers_output(output);
assert!(providers.get("openai").copied().unwrap_or(false));
assert!(providers.get("google").copied().unwrap_or(false));
assert!(providers.get("openrouter").copied().unwrap_or(false));
assert!(!providers.contains_key("credentials"));
}
#[test]
fn test_parse_providers_empty() {
assert!(parse_providers_output("").is_empty());
}
#[test]
fn test_parse_models_basic() {
let output = r#"opencode/big-pickle
google/gemini-2.5-pro
openai/gpt-5.4
openrouter/anthropic/claude-opus-4.7"#;
let slugs = parse_models_output(output);
assert_eq!(slugs.len(), 4);
assert!(slugs.contains(&"openai/gpt-5.4".to_string()));
assert!(slugs.contains(&"openrouter/anthropic/claude-opus-4.7".to_string()));
}
#[test]
fn test_parse_models_filters_invalid() {
let slugs = parse_models_output("some-invalid-line\nopenai/gpt-5.4\n\n");
assert_eq!(slugs, vec!["openai/gpt-5.4"]);
}
#[test]
fn test_parse_models_strips_ansi() {
let slugs = parse_models_output("\x1b[32mopenai/gpt-5.4\x1b[0m");
assert_eq!(slugs, vec!["openai/gpt-5.4"]);
}
}