use anyhow::Result;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::profile;
use crate::rates::Rates;
const RATES_STALE_DAYS: i64 = 90;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Status {
Ok,
Warn,
Fail,
}
impl Status {
fn marker(self) -> &'static str {
match self {
Status::Ok => "[ok]",
Status::Warn => "[warn]",
Status::Fail => "[fail]",
}
}
}
fn report(status: Status, name: &str, detail: &str) {
println!("{} {name}: {detail}", status.marker());
}
pub fn run() -> Result<i32> {
let mut any_fail = false;
any_fail |= check_claude() == Status::Fail;
let _ = check_auth();
any_fail |= check_config() == Status::Fail;
any_fail |= check_rates() == Status::Fail;
Ok(if any_fail { 1 } else { 0 })
}
fn check_claude() -> Status {
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 detail = if version.is_empty() {
"found on PATH".to_string()
} else {
version
};
report(Status::Ok, "claude", &detail);
Status::Ok
}
Ok(_) | Err(_) => {
report(
Status::Fail,
"claude",
"not found on PATH -- install claude-code (https://github.com/anthropics/claude-code)",
);
Status::Fail
}
}
}
fn check_auth() -> Status {
match std::env::var("ANTHROPIC_API_KEY") {
Ok(v) if !v.is_empty() => {
report(Status::Ok, "auth", "ANTHROPIC_API_KEY set");
Status::Ok
}
_ => {
report(
Status::Warn,
"auth",
"ANTHROPIC_API_KEY not set (OAuth/keychain not verifiable here; --bare needs the API key)",
);
Status::Warn
}
}
}
fn check_config() -> Status {
match profile::load_pool() {
Ok(pool) if pool.sources.is_empty() => {
report(Status::Ok, "config", "no roba.toml found");
Status::Ok
}
Ok(pool) => {
let files = pool
.sources
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ");
report(
Status::Ok,
"config",
&format!("{} file(s): {files}", pool.sources.len()),
);
Status::Ok
}
Err(e) => {
report(Status::Fail, "config", &format!("{e:#}"));
Status::Fail
}
}
}
fn check_rates() -> Status {
let rates = match Rates::bundled() {
Ok(r) => r,
Err(e) => {
report(Status::Fail, "rates", &format!("{e:#}"));
return Status::Fail;
}
};
let as_of = &rates.meta.as_of;
match days_since(as_of) {
Some(days) if days > RATES_STALE_DAYS => {
report(
Status::Warn,
"rates",
&format!("as_of {as_of} ({days} days old; prices may be stale)"),
);
Status::Warn
}
Some(days) => {
report(
Status::Ok,
"rates",
&format!("as_of {as_of} ({days} days old)"),
);
Status::Ok
}
None => {
report(
Status::Warn,
"rates",
&format!("as_of {as_of} (could not parse date)"),
);
Status::Warn
}
}
}
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 status_markers() {
assert_eq!(Status::Ok.marker(), "[ok]");
assert_eq!(Status::Warn.marker(), "[warn]");
assert_eq!(Status::Fail.marker(), "[fail]");
}
}