969 lines
30 KiB
Rust
969 lines
30 KiB
Rust
use crate::config::Config;
|
||
use anyhow::Result;
|
||
use chrono::{DateTime, Utc};
|
||
use std::io::Write;
|
||
use std::path::Path;
|
||
|
||
const DAEMON_STALE_SECONDS: i64 = 30;
|
||
const SCHEDULER_STALE_SECONDS: i64 = 120;
|
||
const CHANNEL_STALE_SECONDS: i64 = 300;
|
||
const COMMAND_VERSION_PREVIEW_CHARS: usize = 60;
|
||
|
||
// ── Diagnostic item ──────────────────────────────────────────────
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum Severity {
|
||
Ok,
|
||
Warn,
|
||
Error,
|
||
}
|
||
|
||
struct DiagItem {
|
||
severity: Severity,
|
||
category: &'static str,
|
||
message: String,
|
||
}
|
||
|
||
impl DiagItem {
|
||
fn ok(category: &'static str, msg: impl Into<String>) -> Self {
|
||
Self {
|
||
severity: Severity::Ok,
|
||
category,
|
||
message: msg.into(),
|
||
}
|
||
}
|
||
fn warn(category: &'static str, msg: impl Into<String>) -> Self {
|
||
Self {
|
||
severity: Severity::Warn,
|
||
category,
|
||
message: msg.into(),
|
||
}
|
||
}
|
||
fn error(category: &'static str, msg: impl Into<String>) -> Self {
|
||
Self {
|
||
severity: Severity::Error,
|
||
category,
|
||
message: msg.into(),
|
||
}
|
||
}
|
||
|
||
fn icon(&self) -> &'static str {
|
||
match self.severity {
|
||
Severity::Ok => "✅",
|
||
Severity::Warn => "⚠️ ",
|
||
Severity::Error => "❌",
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Public entry point ───────────────────────────────────────────
|
||
|
||
pub fn run(config: &Config) -> Result<()> {
|
||
let mut items: Vec<DiagItem> = Vec::new();
|
||
|
||
check_config_semantics(config, &mut items);
|
||
check_workspace(config, &mut items);
|
||
check_daemon_state(config, &mut items);
|
||
check_environment(&mut items);
|
||
|
||
// Print report
|
||
println!("🩺 ZeroClaw Doctor (enhanced)");
|
||
println!();
|
||
|
||
let mut current_cat = "";
|
||
for item in &items {
|
||
if item.category != current_cat {
|
||
current_cat = item.category;
|
||
println!(" [{current_cat}]");
|
||
}
|
||
println!(" {} {}", item.icon(), item.message);
|
||
}
|
||
|
||
let errors = items
|
||
.iter()
|
||
.filter(|i| i.severity == Severity::Error)
|
||
.count();
|
||
let warns = items
|
||
.iter()
|
||
.filter(|i| i.severity == Severity::Warn)
|
||
.count();
|
||
let oks = items.iter().filter(|i| i.severity == Severity::Ok).count();
|
||
|
||
println!();
|
||
println!(" Summary: {oks} ok, {warns} warnings, {errors} errors");
|
||
|
||
if errors > 0 {
|
||
println!(" 💡 Fix the errors above, then run `zeroclaw doctor` again.");
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum ModelProbeOutcome {
|
||
Ok,
|
||
Skipped,
|
||
AuthOrAccess,
|
||
Error,
|
||
}
|
||
|
||
fn classify_model_probe_error(err_message: &str) -> ModelProbeOutcome {
|
||
let lower = err_message.to_lowercase();
|
||
|
||
if lower.contains("does not support live model discovery") {
|
||
return ModelProbeOutcome::Skipped;
|
||
}
|
||
|
||
if [
|
||
"401",
|
||
"403",
|
||
"429",
|
||
"unauthorized",
|
||
"forbidden",
|
||
"api key",
|
||
"token",
|
||
"insufficient balance",
|
||
"insufficient quota",
|
||
"plan does not include",
|
||
"rate limit",
|
||
]
|
||
.iter()
|
||
.any(|hint| lower.contains(hint))
|
||
{
|
||
return ModelProbeOutcome::AuthOrAccess;
|
||
}
|
||
|
||
ModelProbeOutcome::Error
|
||
}
|
||
|
||
fn doctor_model_targets(provider_override: Option<&str>) -> Vec<String> {
|
||
if let Some(provider) = provider_override.map(str::trim).filter(|p| !p.is_empty()) {
|
||
return vec![provider.to_string()];
|
||
}
|
||
|
||
crate::providers::list_providers()
|
||
.into_iter()
|
||
.map(|provider| provider.name.to_string())
|
||
.collect()
|
||
}
|
||
|
||
pub fn run_models(config: &Config, provider_override: Option<&str>, use_cache: bool) -> Result<()> {
|
||
let targets = doctor_model_targets(provider_override);
|
||
|
||
if targets.is_empty() {
|
||
anyhow::bail!("No providers available for model probing");
|
||
}
|
||
|
||
println!("🩺 ZeroClaw Doctor — Model Catalog Probe");
|
||
println!(" Providers to probe: {}", targets.len());
|
||
println!(
|
||
" Mode: {}",
|
||
if use_cache {
|
||
"cache-first"
|
||
} else {
|
||
"force live refresh"
|
||
}
|
||
);
|
||
println!();
|
||
|
||
let mut ok_count = 0usize;
|
||
let mut skipped_count = 0usize;
|
||
let mut auth_count = 0usize;
|
||
let mut error_count = 0usize;
|
||
|
||
for provider_name in &targets {
|
||
println!(" [{}]", provider_name);
|
||
|
||
match crate::onboard::run_models_refresh(config, Some(provider_name), !use_cache) {
|
||
Ok(()) => {
|
||
ok_count += 1;
|
||
println!(" ✅ model catalog check passed");
|
||
}
|
||
Err(error) => {
|
||
let error_text = format_error_chain(&error);
|
||
match classify_model_probe_error(&error_text) {
|
||
ModelProbeOutcome::Skipped => {
|
||
skipped_count += 1;
|
||
println!(" ⚪ skipped: {}", truncate_for_display(&error_text, 160));
|
||
}
|
||
ModelProbeOutcome::AuthOrAccess => {
|
||
auth_count += 1;
|
||
println!(
|
||
" ⚠️ auth/access: {}",
|
||
truncate_for_display(&error_text, 160)
|
||
);
|
||
}
|
||
ModelProbeOutcome::Error => {
|
||
error_count += 1;
|
||
println!(" ❌ error: {}", truncate_for_display(&error_text, 160));
|
||
}
|
||
ModelProbeOutcome::Ok => {
|
||
ok_count += 1;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
println!();
|
||
}
|
||
|
||
println!(
|
||
" Summary: {} ok, {} skipped, {} auth/access, {} errors",
|
||
ok_count, skipped_count, auth_count, error_count
|
||
);
|
||
|
||
if auth_count > 0 {
|
||
println!(
|
||
" 💡 Some providers need valid API keys/plan access before `/models` can be fetched."
|
||
);
|
||
}
|
||
|
||
if provider_override.is_some() && ok_count == 0 {
|
||
anyhow::bail!("Model probe failed for target provider")
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ── Config semantic validation ───────────────────────────────────
|
||
|
||
fn check_config_semantics(config: &Config, items: &mut Vec<DiagItem>) {
|
||
let cat = "config";
|
||
|
||
// Config file exists
|
||
if config.config_path.exists() {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!("config file: {}", config.config_path.display()),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("config file not found: {}", config.config_path.display()),
|
||
));
|
||
}
|
||
|
||
// Provider validity
|
||
if let Some(ref provider) = config.default_provider {
|
||
if let Some(reason) = provider_validation_error(provider) {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("default provider \"{provider}\" is invalid: {reason}"),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!("provider \"{provider}\" is valid"),
|
||
));
|
||
}
|
||
} else {
|
||
items.push(DiagItem::error(cat, "no default_provider configured"));
|
||
}
|
||
|
||
// API key presence
|
||
if config.default_provider.as_deref() != Some("ollama") {
|
||
if config.api_key.is_some() {
|
||
items.push(DiagItem::ok(cat, "API key configured"));
|
||
} else {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
"no api_key set (may rely on env vars or provider defaults)",
|
||
));
|
||
}
|
||
}
|
||
|
||
// Model configured
|
||
if config.default_model.is_some() {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!(
|
||
"default model: {}",
|
||
config.default_model.as_deref().unwrap_or("?")
|
||
),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::warn(cat, "no default_model configured"));
|
||
}
|
||
|
||
// Temperature range
|
||
if config.default_temperature >= 0.0 && config.default_temperature <= 2.0 {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!(
|
||
"temperature {:.1} (valid range 0.0–2.0)",
|
||
config.default_temperature
|
||
),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!(
|
||
"temperature {:.1} is out of range (expected 0.0–2.0)",
|
||
config.default_temperature
|
||
),
|
||
));
|
||
}
|
||
|
||
// Gateway port range
|
||
let port = config.gateway.port;
|
||
if port > 0 {
|
||
items.push(DiagItem::ok(cat, format!("gateway port: {port}")));
|
||
} else {
|
||
items.push(DiagItem::error(cat, "gateway port is 0 (invalid)"));
|
||
}
|
||
|
||
// Reliability: fallback providers
|
||
for fb in &config.reliability.fallback_providers {
|
||
if let Some(reason) = provider_validation_error(fb) {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!("fallback provider \"{fb}\" is invalid: {reason}"),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Model routes validation
|
||
for route in &config.model_routes {
|
||
if route.hint.is_empty() {
|
||
items.push(DiagItem::warn(cat, "model route with empty hint"));
|
||
}
|
||
if let Some(reason) = provider_validation_error(&route.provider) {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!(
|
||
"model route \"{}\" uses invalid provider \"{}\": {}",
|
||
route.hint, route.provider, reason
|
||
),
|
||
));
|
||
}
|
||
if route.model.is_empty() {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!("model route \"{}\" has empty model", route.hint),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Channel: at least one configured
|
||
let cc = &config.channels_config;
|
||
let has_channel = cc.telegram.is_some()
|
||
|| cc.discord.is_some()
|
||
|| cc.slack.is_some()
|
||
|| cc.imessage.is_some()
|
||
|| cc.matrix.is_some()
|
||
|| cc.whatsapp.is_some()
|
||
|| cc.email.is_some()
|
||
|| cc.irc.is_some()
|
||
|| cc.lark.is_some()
|
||
|| cc.webhook.is_some();
|
||
|
||
if has_channel {
|
||
items.push(DiagItem::ok(cat, "at least one channel configured"));
|
||
} else {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
"no channels configured — run `zeroclaw onboard` to set one up",
|
||
));
|
||
}
|
||
|
||
// Delegate agents: provider validity
|
||
let mut agent_names: Vec<_> = config.agents.keys().collect();
|
||
agent_names.sort();
|
||
for name in agent_names {
|
||
let agent = config.agents.get(name).unwrap();
|
||
if let Some(reason) = provider_validation_error(&agent.provider) {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!(
|
||
"agent \"{name}\" uses invalid provider \"{}\": {}",
|
||
agent.provider, reason
|
||
),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn provider_validation_error(name: &str) -> Option<String> {
|
||
match crate::providers::create_provider(name, None) {
|
||
Ok(_) => None,
|
||
Err(err) => Some(
|
||
err.to_string()
|
||
.lines()
|
||
.next()
|
||
.unwrap_or("invalid provider")
|
||
.into(),
|
||
),
|
||
}
|
||
}
|
||
|
||
// ── Workspace integrity ──────────────────────────────────────────
|
||
|
||
fn check_workspace(config: &Config, items: &mut Vec<DiagItem>) {
|
||
let cat = "workspace";
|
||
let ws = &config.workspace_dir;
|
||
|
||
if ws.exists() {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!("directory exists: {}", ws.display()),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("directory missing: {}", ws.display()),
|
||
));
|
||
return;
|
||
}
|
||
|
||
// Writable check
|
||
let probe = workspace_probe_path(ws);
|
||
match std::fs::OpenOptions::new()
|
||
.write(true)
|
||
.create_new(true)
|
||
.open(&probe)
|
||
{
|
||
Ok(mut probe_file) => {
|
||
let write_result = probe_file.write_all(b"probe");
|
||
drop(probe_file);
|
||
let _ = std::fs::remove_file(&probe);
|
||
match write_result {
|
||
Ok(()) => items.push(DiagItem::ok(cat, "directory is writable")),
|
||
Err(e) => items.push(DiagItem::error(
|
||
cat,
|
||
format!("directory write probe failed: {e}"),
|
||
)),
|
||
}
|
||
}
|
||
Err(e) => {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("directory is not writable: {e}"),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Disk space (best-effort via `df`)
|
||
if let Some(avail_mb) = disk_available_mb(ws) {
|
||
if avail_mb >= 100 {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!("disk space: {avail_mb} MB available"),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!("low disk space: only {avail_mb} MB available"),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Key workspace files
|
||
check_file_exists(ws, "SOUL.md", false, cat, items);
|
||
check_file_exists(ws, "AGENTS.md", false, cat, items);
|
||
}
|
||
|
||
fn check_file_exists(
|
||
base: &Path,
|
||
name: &str,
|
||
required: bool,
|
||
cat: &'static str,
|
||
items: &mut Vec<DiagItem>,
|
||
) {
|
||
let path = base.join(name);
|
||
if path.is_file() {
|
||
items.push(DiagItem::ok(cat, format!("{name} present")));
|
||
} else if required {
|
||
items.push(DiagItem::error(cat, format!("{name} missing")));
|
||
} else {
|
||
items.push(DiagItem::warn(cat, format!("{name} not found (optional)")));
|
||
}
|
||
}
|
||
|
||
fn disk_available_mb(path: &Path) -> Option<u64> {
|
||
let output = std::process::Command::new("df")
|
||
.arg("-m")
|
||
.arg(path)
|
||
.output()
|
||
.ok()?;
|
||
if !output.status.success() {
|
||
return None;
|
||
}
|
||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||
parse_df_available_mb(&stdout)
|
||
}
|
||
|
||
fn parse_df_available_mb(stdout: &str) -> Option<u64> {
|
||
let line = stdout.lines().rev().find(|line| !line.trim().is_empty())?;
|
||
let avail = line.split_whitespace().nth(3)?;
|
||
avail.parse::<u64>().ok()
|
||
}
|
||
|
||
fn workspace_probe_path(workspace_dir: &Path) -> std::path::PathBuf {
|
||
let nanos = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map_or(0, |duration| duration.as_nanos());
|
||
workspace_dir.join(format!(
|
||
".zeroclaw_doctor_probe_{}_{}",
|
||
std::process::id(),
|
||
nanos
|
||
))
|
||
}
|
||
|
||
// ── Daemon state (original logic, preserved) ─────────────────────
|
||
|
||
fn check_daemon_state(config: &Config, items: &mut Vec<DiagItem>) {
|
||
let cat = "daemon";
|
||
let state_file = crate::daemon::state_file_path(config);
|
||
|
||
if !state_file.exists() {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!(
|
||
"state file not found: {} — is the daemon running?",
|
||
state_file.display()
|
||
),
|
||
));
|
||
return;
|
||
}
|
||
|
||
let raw = match std::fs::read_to_string(&state_file) {
|
||
Ok(r) => r,
|
||
Err(e) => {
|
||
items.push(DiagItem::error(cat, format!("cannot read state file: {e}")));
|
||
return;
|
||
}
|
||
};
|
||
|
||
let snapshot: serde_json::Value = match serde_json::from_str(&raw) {
|
||
Ok(v) => v,
|
||
Err(e) => {
|
||
items.push(DiagItem::error(cat, format!("invalid state JSON: {e}")));
|
||
return;
|
||
}
|
||
};
|
||
|
||
// Daemon heartbeat freshness
|
||
let updated_at = snapshot
|
||
.get("updated_at")
|
||
.and_then(serde_json::Value::as_str)
|
||
.unwrap_or("");
|
||
|
||
if let Ok(ts) = DateTime::parse_from_rfc3339(updated_at) {
|
||
let age = Utc::now()
|
||
.signed_duration_since(ts.with_timezone(&Utc))
|
||
.num_seconds();
|
||
if age <= DAEMON_STALE_SECONDS {
|
||
items.push(DiagItem::ok(cat, format!("heartbeat fresh ({age}s ago)")));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("heartbeat stale ({age}s ago)"),
|
||
));
|
||
}
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("invalid daemon timestamp: {updated_at}"),
|
||
));
|
||
}
|
||
|
||
// Components
|
||
if let Some(components) = snapshot
|
||
.get("components")
|
||
.and_then(serde_json::Value::as_object)
|
||
{
|
||
// Scheduler
|
||
if let Some(scheduler) = components.get("scheduler") {
|
||
let scheduler_ok = scheduler
|
||
.get("status")
|
||
.and_then(serde_json::Value::as_str)
|
||
.is_some_and(|s| s == "ok");
|
||
let scheduler_age = scheduler
|
||
.get("last_ok")
|
||
.and_then(serde_json::Value::as_str)
|
||
.and_then(parse_rfc3339)
|
||
.map_or(i64::MAX, |dt| {
|
||
Utc::now().signed_duration_since(dt).num_seconds()
|
||
});
|
||
|
||
if scheduler_ok && scheduler_age <= SCHEDULER_STALE_SECONDS {
|
||
items.push(DiagItem::ok(
|
||
cat,
|
||
format!("scheduler healthy (last ok {scheduler_age}s ago)"),
|
||
));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("scheduler unhealthy (ok={scheduler_ok}, age={scheduler_age}s)"),
|
||
));
|
||
}
|
||
} else {
|
||
items.push(DiagItem::warn(cat, "scheduler component not tracked yet"));
|
||
}
|
||
|
||
// Channels
|
||
let mut channel_count = 0u32;
|
||
let mut stale = 0u32;
|
||
for (name, component) in components {
|
||
if !name.starts_with("channel:") {
|
||
continue;
|
||
}
|
||
channel_count += 1;
|
||
let status_ok = component
|
||
.get("status")
|
||
.and_then(serde_json::Value::as_str)
|
||
.is_some_and(|s| s == "ok");
|
||
let age = component
|
||
.get("last_ok")
|
||
.and_then(serde_json::Value::as_str)
|
||
.and_then(parse_rfc3339)
|
||
.map_or(i64::MAX, |dt| {
|
||
Utc::now().signed_duration_since(dt).num_seconds()
|
||
});
|
||
|
||
if status_ok && age <= CHANNEL_STALE_SECONDS {
|
||
items.push(DiagItem::ok(cat, format!("{name} fresh ({age}s ago)")));
|
||
} else {
|
||
stale += 1;
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
format!("{name} stale (ok={status_ok}, age={age}s)"),
|
||
));
|
||
}
|
||
}
|
||
|
||
if channel_count == 0 {
|
||
items.push(DiagItem::warn(cat, "no channel components tracked yet"));
|
||
} else if stale > 0 {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!("{channel_count} channels, {stale} stale"),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Environment checks ───────────────────────────────────────────
|
||
|
||
fn check_environment(items: &mut Vec<DiagItem>) {
|
||
let cat = "environment";
|
||
|
||
// git
|
||
check_command_available("git", &["--version"], cat, items);
|
||
|
||
// Shell
|
||
let shell = std::env::var("SHELL").unwrap_or_default();
|
||
if shell.is_empty() {
|
||
items.push(DiagItem::warn(cat, "$SHELL not set"));
|
||
} else {
|
||
items.push(DiagItem::ok(cat, format!("shell: {shell}")));
|
||
}
|
||
|
||
// HOME
|
||
if std::env::var("HOME").is_ok() || std::env::var("USERPROFILE").is_ok() {
|
||
items.push(DiagItem::ok(cat, "home directory env set"));
|
||
} else {
|
||
items.push(DiagItem::error(
|
||
cat,
|
||
"neither $HOME nor $USERPROFILE is set",
|
||
));
|
||
}
|
||
|
||
// Optional tools
|
||
check_command_available("curl", &["--version"], cat, items);
|
||
}
|
||
|
||
fn check_command_available(cmd: &str, args: &[&str], cat: &'static str, items: &mut Vec<DiagItem>) {
|
||
match std::process::Command::new(cmd)
|
||
.args(args)
|
||
.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::piped())
|
||
.output()
|
||
{
|
||
Ok(output) if output.status.success() => {
|
||
let ver = String::from_utf8_lossy(&output.stdout);
|
||
let first_line = ver.lines().next().unwrap_or("").trim();
|
||
let display = truncate_for_display(first_line, COMMAND_VERSION_PREVIEW_CHARS);
|
||
items.push(DiagItem::ok(cat, format!("{cmd}: {display}")));
|
||
}
|
||
Ok(_) => {
|
||
items.push(DiagItem::warn(
|
||
cat,
|
||
format!("{cmd} found but returned non-zero"),
|
||
));
|
||
}
|
||
Err(_) => {
|
||
items.push(DiagItem::warn(cat, format!("{cmd} not found in PATH")));
|
||
}
|
||
}
|
||
}
|
||
|
||
fn format_error_chain(error: &anyhow::Error) -> String {
|
||
let mut parts = Vec::new();
|
||
for cause in error.chain() {
|
||
let message = cause.to_string();
|
||
if !message.is_empty() {
|
||
parts.push(message);
|
||
}
|
||
}
|
||
|
||
if parts.is_empty() {
|
||
return String::new();
|
||
}
|
||
|
||
parts.join(": ")
|
||
}
|
||
|
||
fn truncate_for_display(input: &str, max_chars: usize) -> String {
|
||
let mut chars = input.chars();
|
||
let preview: String = chars.by_ref().take(max_chars).collect();
|
||
if chars.next().is_some() {
|
||
format!("{preview}…")
|
||
} else {
|
||
preview
|
||
}
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────
|
||
|
||
fn parse_rfc3339(raw: &str) -> Option<DateTime<Utc>> {
|
||
DateTime::parse_from_rfc3339(raw)
|
||
.ok()
|
||
.map(|dt| dt.with_timezone(&Utc))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use tempfile::TempDir;
|
||
|
||
#[test]
|
||
fn provider_validation_checks_custom_url_shape() {
|
||
assert!(provider_validation_error("openrouter").is_none());
|
||
assert!(provider_validation_error("custom:https://example.com").is_none());
|
||
assert!(provider_validation_error("anthropic-custom:https://example.com").is_none());
|
||
|
||
let invalid_custom = provider_validation_error("custom:").unwrap_or_default();
|
||
assert!(invalid_custom.contains("requires a URL"));
|
||
|
||
let invalid_unknown = provider_validation_error("totally-fake").unwrap_or_default();
|
||
assert!(invalid_unknown.contains("Unknown provider"));
|
||
}
|
||
|
||
#[test]
|
||
fn diag_item_icons() {
|
||
assert_eq!(DiagItem::ok("t", "m").icon(), "✅");
|
||
assert_eq!(DiagItem::warn("t", "m").icon(), "⚠️ ");
|
||
assert_eq!(DiagItem::error("t", "m").icon(), "❌");
|
||
}
|
||
|
||
#[test]
|
||
fn classify_model_probe_error_marks_unsupported_as_skipped() {
|
||
let outcome = classify_model_probe_error(
|
||
"Provider 'copilot' does not support live model discovery yet",
|
||
);
|
||
assert_eq!(outcome, ModelProbeOutcome::Skipped);
|
||
}
|
||
|
||
#[test]
|
||
fn classify_model_probe_error_marks_auth_and_plan_issues() {
|
||
let auth_outcome = classify_model_probe_error("OpenAI API error (401): unauthorized");
|
||
assert_eq!(auth_outcome, ModelProbeOutcome::AuthOrAccess);
|
||
|
||
let plan_outcome = classify_model_probe_error(
|
||
"Z.AI API error (429): plan does not include requested model",
|
||
);
|
||
assert_eq!(plan_outcome, ModelProbeOutcome::AuthOrAccess);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_catches_bad_temperature() {
|
||
let mut config = Config::default();
|
||
config.default_temperature = 5.0;
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let temp_item = items.iter().find(|i| i.message.contains("temperature"));
|
||
assert!(temp_item.is_some());
|
||
assert_eq!(temp_item.unwrap().severity, Severity::Error);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_accepts_valid_temperature() {
|
||
let mut config = Config::default();
|
||
config.default_temperature = 0.7;
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let temp_item = items.iter().find(|i| i.message.contains("temperature"));
|
||
assert!(temp_item.is_some());
|
||
assert_eq!(temp_item.unwrap().severity, Severity::Ok);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_warns_no_channels() {
|
||
let config = Config::default();
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let ch_item = items.iter().find(|i| i.message.contains("channel"));
|
||
assert!(ch_item.is_some());
|
||
assert_eq!(ch_item.unwrap().severity, Severity::Warn);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_catches_unknown_provider() {
|
||
let mut config = Config::default();
|
||
config.default_provider = Some("totally-fake".into());
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let prov_item = items
|
||
.iter()
|
||
.find(|i| i.message.contains("default provider"));
|
||
assert!(prov_item.is_some());
|
||
assert_eq!(prov_item.unwrap().severity, Severity::Error);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_catches_malformed_custom_provider() {
|
||
let mut config = Config::default();
|
||
config.default_provider = Some("custom:".into());
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
|
||
let prov_item = items.iter().find(|item| {
|
||
item.message
|
||
.contains("default provider \"custom:\" is invalid")
|
||
});
|
||
assert!(prov_item.is_some());
|
||
assert_eq!(prov_item.unwrap().severity, Severity::Error);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_accepts_custom_provider() {
|
||
let mut config = Config::default();
|
||
config.default_provider = Some("custom:https://my-api.com".into());
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let prov_item = items.iter().find(|i| i.message.contains("is valid"));
|
||
assert!(prov_item.is_some());
|
||
assert_eq!(prov_item.unwrap().severity, Severity::Ok);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_warns_bad_fallback() {
|
||
let mut config = Config::default();
|
||
config.reliability.fallback_providers = vec!["fake-provider".into()];
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let fb_item = items
|
||
.iter()
|
||
.find(|i| i.message.contains("fallback provider"));
|
||
assert!(fb_item.is_some());
|
||
assert_eq!(fb_item.unwrap().severity, Severity::Warn);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_warns_bad_custom_fallback() {
|
||
let mut config = Config::default();
|
||
config.reliability.fallback_providers = vec!["custom:".into()];
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
|
||
let fb_item = items.iter().find(|item| {
|
||
item.message
|
||
.contains("fallback provider \"custom:\" is invalid")
|
||
});
|
||
assert!(fb_item.is_some());
|
||
assert_eq!(fb_item.unwrap().severity, Severity::Warn);
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_warns_empty_model_route() {
|
||
let mut config = Config::default();
|
||
config.model_routes = vec![crate::config::ModelRouteConfig {
|
||
hint: "fast".into(),
|
||
provider: "groq".into(),
|
||
model: String::new(),
|
||
api_key: None,
|
||
}];
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
let route_item = items.iter().find(|i| i.message.contains("empty model"));
|
||
assert!(route_item.is_some());
|
||
assert_eq!(route_item.unwrap().severity, Severity::Warn);
|
||
}
|
||
|
||
#[test]
|
||
fn environment_check_finds_git() {
|
||
let mut items = Vec::new();
|
||
check_environment(&mut items);
|
||
let git_item = items.iter().find(|i| i.message.starts_with("git:"));
|
||
// git should be available in any CI/dev environment
|
||
assert!(git_item.is_some());
|
||
assert_eq!(git_item.unwrap().severity, Severity::Ok);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_df_available_mb_uses_last_data_line() {
|
||
let stdout =
|
||
"Filesystem 1M-blocks Used Available Use% Mounted on\n/dev/sda1 1000 500 500 50% /\n";
|
||
assert_eq!(parse_df_available_mb(stdout), Some(500));
|
||
}
|
||
|
||
#[test]
|
||
fn truncate_for_display_preserves_utf8_boundaries() {
|
||
let preview = truncate_for_display("版本号-alpha-build", 3);
|
||
assert_eq!(preview, "版本号…");
|
||
}
|
||
|
||
#[test]
|
||
fn workspace_probe_path_is_hidden_and_unique() {
|
||
let tmp = TempDir::new().unwrap();
|
||
let first = workspace_probe_path(tmp.path());
|
||
let second = workspace_probe_path(tmp.path());
|
||
|
||
assert_ne!(first, second);
|
||
assert!(first
|
||
.file_name()
|
||
.and_then(|name| name.to_str())
|
||
.is_some_and(|name| name.starts_with(".zeroclaw_doctor_probe_")));
|
||
}
|
||
|
||
#[test]
|
||
fn config_validation_reports_delegate_agents_in_sorted_order() {
|
||
let mut config = Config::default();
|
||
config.agents.insert(
|
||
"zeta".into(),
|
||
crate::config::DelegateAgentConfig {
|
||
provider: "totally-fake".into(),
|
||
model: "model-z".into(),
|
||
system_prompt: None,
|
||
api_key: None,
|
||
temperature: None,
|
||
max_depth: 3,
|
||
},
|
||
);
|
||
config.agents.insert(
|
||
"alpha".into(),
|
||
crate::config::DelegateAgentConfig {
|
||
provider: "totally-fake".into(),
|
||
model: "model-a".into(),
|
||
system_prompt: None,
|
||
api_key: None,
|
||
temperature: None,
|
||
max_depth: 3,
|
||
},
|
||
);
|
||
|
||
let mut items = Vec::new();
|
||
check_config_semantics(&config, &mut items);
|
||
|
||
let agent_messages: Vec<_> = items
|
||
.iter()
|
||
.filter(|item| item.message.starts_with("agent \""))
|
||
.map(|item| item.message.as_str())
|
||
.collect();
|
||
|
||
assert_eq!(agent_messages.len(), 2);
|
||
assert!(agent_messages[0].contains("agent \"alpha\""));
|
||
assert!(agent_messages[1].contains("agent \"zeta\""));
|
||
}
|
||
}
|