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;
use crate::style;
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]",
}
}
fn color(self) -> &'static str {
match self {
Status::Ok => style::HEADER,
Status::Warn => style::WARN,
Status::Fail => style::ERROR,
}
}
}
fn render_human(checks: &[Check], color: bool) -> String {
let marker_w = checks
.iter()
.map(|c| c.status.marker().len())
.max()
.unwrap_or(0);
let name_w = checks.iter().map(|c| c.name.len()).max().unwrap_or(0);
let mut out = String::new();
for c in checks {
let marker = c.status.marker();
out.push_str(&format!(
"{}{} {}{} {}\n",
style::paint(marker, c.status.color(), color),
" ".repeat(marker_w - marker.len()),
style::paint(c.name, style::KEY, color),
" ".repeat(name_w - c.name.len()),
c.message,
));
}
out
}
#[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 {
let color = style::color_enabled(args.plain);
print!("{}", render_human(&report.checks, color));
}
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 named(name: &'static str, status: Status) -> Check {
Check {
name,
status,
message: "msg".to_string(),
}
}
#[test]
fn render_human_plain_aligns_and_leaks_no_ansi() {
let checks = vec![named("claude", Status::Ok), named("auth", Status::Warn)];
let text = render_human(&checks, false);
assert!(!text.contains('\x1b'), "plain mode leaked ANSI:\n{text}");
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "[ok] claude msg");
assert_eq!(lines[1], "[warn] auth msg");
let col0 = lines[0].find("msg").unwrap();
let col1 = lines[1].find("msg").unwrap();
assert_eq!(col0, col1, "message column not aligned:\n{text}");
}
#[test]
fn render_human_color_wraps_marker_and_name() {
let checks = vec![named("auth", Status::Warn)];
let text = render_human(&checks, true);
assert!(text.contains("\x1b[1;33m[warn]\x1b[0m"), "{text}");
assert!(text.contains("\x1b[36mauth\x1b[0m"), "{text}");
}
#[test]
fn status_color_distinct_per_status() {
assert_eq!(Status::Ok.color(), "\x1b[1;32m");
assert_eq!(Status::Warn.color(), "\x1b[1;33m");
assert_eq!(Status::Fail.color(), "\x1b[1;31m");
}
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");
}
}