use anyhow::Result;
use serde::Serialize;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::cli::DoctorArgs;
use crate::profile;
use crate::rates::Rates;
const RATES_STALE_DAYS: i64 = 90;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
enum Status {
Ok,
Warn,
Fail,
}
impl Status {
fn marker(self) -> &'static str {
match self {
Status::Ok => "[ok]",
Status::Warn => "[warn]",
Status::Fail => "[fail]",
}
}
}
#[derive(Debug, Serialize)]
struct Check {
name: &'static str,
status: Status,
message: String,
}
#[derive(Debug, Serialize)]
struct Report {
checks: Vec<Check>,
overall: Status,
}
pub fn run(args: DoctorArgs) -> Result<i32> {
let checks = vec![check_claude(), check_auth(), check_config(), check_rates()];
let overall = overall_status(&checks);
let exit = if overall == Status::Fail { 1 } else { 0 };
let report = Report { checks, overall };
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&crate::VersionedResult::new(&report))?
);
} else {
for c in &report.checks {
println!("{} {}: {}", c.status.marker(), c.name, c.message);
}
}
Ok(exit)
}
fn overall_status(checks: &[Check]) -> Status {
if checks.iter().any(|c| c.status == Status::Fail) {
Status::Fail
} else if checks.iter().any(|c| c.status == Status::Warn) {
Status::Warn
} else {
Status::Ok
}
}
pub(crate) fn claude_on_path() -> bool {
matches!(Command::new("claude").arg("--version").output(), Ok(out) if out.status.success())
}
fn check_claude() -> Check {
match Command::new("claude").arg("--version").output() {
Ok(out) if out.status.success() => {
let version = String::from_utf8_lossy(&out.stdout).trim().to_string();
let message = if version.is_empty() {
"found on PATH".to_string()
} else {
version
};
Check {
name: "claude",
status: Status::Ok,
message,
}
}
Ok(_) | Err(_) => Check {
name: "claude",
status: Status::Fail,
message:
"not found on PATH -- install claude-code (https://github.com/anthropics/claude-code)"
.to_string(),
},
}
}
fn check_auth() -> Check {
let key = std::env::var("ANTHROPIC_API_KEY").ok();
let (status, message) = auth_status(key.as_deref());
Check {
name: "auth",
status,
message,
}
}
fn auth_status(key: Option<&str>) -> (Status, String) {
match key {
Some(v) if !v.is_empty() => (Status::Ok, "ANTHROPIC_API_KEY set".to_string()),
_ => (
Status::Warn,
"ANTHROPIC_API_KEY not set -- normal for OAuth/subscription auth (roba's default), \
which can't be verified here; only --bare needs the key. If a real run fails with \
an auth error, run: claude /login"
.to_string(),
),
}
}
fn check_config() -> Check {
let (status, message) = match profile::load_pool() {
Ok(pool) if pool.sources.is_empty() => (Status::Ok, "no roba.toml found".to_string()),
Ok(pool) => {
let files = pool
.sources
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
(
Status::Ok,
format!("{} file(s): {files}", pool.sources.len()),
)
}
Err(e) => (Status::Fail, format!("{e:#}")),
};
Check {
name: "config",
status,
message,
}
}
fn check_rates() -> Check {
let rates = match Rates::bundled() {
Ok(r) => r,
Err(e) => {
return Check {
name: "rates",
status: Status::Fail,
message: format!("{e:#}"),
};
}
};
let as_of = &rates.meta.as_of;
let (status, message) = match days_since(as_of) {
Some(days) if days > RATES_STALE_DAYS => (
Status::Warn,
format!("as_of {as_of} ({days} days old; prices may be stale)"),
),
Some(days) => (Status::Ok, format!("as_of {as_of} ({days} days old)")),
None => (
Status::Warn,
format!("as_of {as_of} (could not parse date)"),
),
};
Check {
name: "rates",
status,
message,
}
}
fn days_since(as_of: &str) -> Option<i64> {
let (y, m, d) = parse_iso_date(as_of)?;
let now = today_days()?;
Some(now - days_from_civil(y, m, d))
}
fn today_days() -> Option<i64> {
let secs = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
Some((secs / 86_400) as i64)
}
fn parse_iso_date(s: &str) -> Option<(i64, i64, i64)> {
let mut parts = s.split('-');
let y = parts.next()?.parse().ok()?;
let m = parts.next()?.parse().ok()?;
let d = parts.next()?.parse().ok()?;
if parts.next().is_some() {
return None;
}
Some((y, m, d))
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = (if y >= 0 { y } else { y - 399 }) / 400;
let yoe = y - era * 400; let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; era * 146_097 + doe - 719_468
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_from_civil_epoch_is_zero() {
assert_eq!(days_from_civil(1970, 1, 1), 0);
}
#[test]
fn days_from_civil_known_offsets() {
assert_eq!(days_from_civil(2000, 1, 1), 10_957);
assert_eq!(days_from_civil(2000, 1, 2), 10_958);
assert_eq!(days_from_civil(2001, 1, 1), 11_323);
}
#[test]
fn days_between_two_dates() {
let a = days_from_civil(2026, 6, 2);
let b = days_from_civil(2026, 9, 1);
assert_eq!(b - a, 91);
}
#[test]
fn parse_iso_date_valid() {
assert_eq!(parse_iso_date("2026-06-02"), Some((2026, 6, 2)));
}
#[test]
fn parse_iso_date_rejects_malformed() {
assert_eq!(parse_iso_date("2026/06/02"), None);
assert_eq!(parse_iso_date("2026-06"), None);
assert_eq!(parse_iso_date("2026-06-02-1"), None);
assert_eq!(parse_iso_date("not-a-date-x"), None);
}
#[test]
fn days_since_future_date_is_negative() {
let d = days_since("2999-01-01").expect("parses");
assert!(d < 0, "future date should be negative, got {d}");
}
#[test]
fn auth_status_key_set_is_ok() {
let (status, detail) = auth_status(Some("sk-ant-xxx"));
assert_eq!(status, Status::Ok);
assert_eq!(detail, "ANTHROPIC_API_KEY set");
}
#[test]
fn auth_status_no_key_warns_informationally() {
for key in [None, Some("")] {
let (status, detail) = auth_status(key);
assert_eq!(status, Status::Warn, "missing key is a warn, not a fail");
assert!(detail.contains("normal for OAuth"), "detail: {detail}");
assert!(detail.contains("--bare"), "detail: {detail}");
assert!(detail.contains("claude /login"), "detail: {detail}");
assert!(!detail.contains("[fail]"), "detail: {detail}");
}
}
#[test]
fn status_markers() {
assert_eq!(Status::Ok.marker(), "[ok]");
assert_eq!(Status::Warn.marker(), "[warn]");
assert_eq!(Status::Fail.marker(), "[fail]");
}
fn check(status: Status) -> Check {
Check {
name: "x",
status,
message: String::new(),
}
}
#[test]
fn overall_status_is_worst() {
assert_eq!(
overall_status(&[check(Status::Ok), check(Status::Warn), check(Status::Fail)]),
Status::Fail
);
assert_eq!(
overall_status(&[check(Status::Ok), check(Status::Warn)]),
Status::Warn
);
assert_eq!(
overall_status(&[check(Status::Ok), check(Status::Ok)]),
Status::Ok
);
}
#[test]
fn status_serializes_as_lowercase_string() {
assert_eq!(serde_json::to_value(Status::Ok).unwrap(), "ok");
assert_eq!(serde_json::to_value(Status::Warn).unwrap(), "warn");
assert_eq!(serde_json::to_value(Status::Fail).unwrap(), "fail");
}
}