diff --git a/crates/arc-cli/src/doctor.rs b/crates/arc-cli/src/doctor.rs index 83a877065..38fe45f5c 100644 --- a/crates/arc-cli/src/doctor.rs +++ b/crates/arc-cli/src/doctor.rs @@ -1,145 +1,14 @@ -use std::fmt::Write; use std::path::PathBuf; use std::process::Command; use std::sync::LazyLock; use arc_api::server_config::{ApiAuthStrategy, AuthProvider}; use arc_llm::provider::Provider; +pub use arc_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckStatus}; use arc_util::terminal::Styles; use regex::Regex; use semver::Version; -// --------------------------------------------------------------------------- -// Core types -// --------------------------------------------------------------------------- - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] -pub enum CheckStatus { - Pass, - Warning, - Error, -} - -#[derive(Debug, Clone)] -pub struct CheckDetail { - pub text: String, -} - -#[derive(Debug, Clone)] -pub struct CheckResult { - pub name: String, - pub status: CheckStatus, - pub summary: String, - pub details: Vec, - pub remediation: Option, -} - -pub struct DoctorReport { - pub checks: Vec, -} - -impl DoctorReport { - pub fn has_errors(&self) -> bool { - self.checks.iter().any(|c| c.status == CheckStatus::Error) - } - - pub fn issue_count(&self) -> usize { - self.checks - .iter() - .filter(|c| matches!(c.status, CheckStatus::Warning | CheckStatus::Error)) - .count() - } - - pub fn render(&self, s: &Styles, verbose: bool, live: bool) -> String { - let mut out = String::new(); - - writeln!(out, "{}", s.bold.apply_to("Arc Doctor")).unwrap(); - writeln!(out).unwrap(); - - for check in &self.checks { - let (icon, color) = match check.status { - CheckStatus::Pass => ("[✓]", &s.green), - CheckStatus::Warning => ("[!]", &s.yellow), - CheckStatus::Error => ("[✗]", &s.red), - }; - - writeln!( - out, - " {} {} ({})", - color.apply_to(icon), - s.bold.apply_to(&check.name), - check.summary, - ) - .unwrap(); - - if verbose { - for detail in &check.details { - writeln!(out, " • {}", detail.text).unwrap(); - } - } - } - - let issues = self.issue_count(); - writeln!(out).unwrap(); - - if issues == 0 { - writeln!(out, "All checks passed.").unwrap(); - } else { - writeln!( - out, - "Doctor found issues in {issues} {}.", - if issues == 1 { - "category" - } else { - "categories" - } - ) - .unwrap(); - - let errors: Vec<_> = self - .checks - .iter() - .filter(|c| c.status == CheckStatus::Error) - .collect(); - if !errors.is_empty() { - writeln!(out).unwrap(); - writeln!(out, "{}", s.bold.apply_to("Errors:")).unwrap(); - for check in &errors { - write!(out, " • {}", check.name).unwrap(); - if let Some(ref rem) = check.remediation { - write!(out, " — {rem}").unwrap(); - } - writeln!(out).unwrap(); - } - } - - let warnings: Vec<_> = self - .checks - .iter() - .filter(|c| c.status == CheckStatus::Warning) - .collect(); - if !warnings.is_empty() { - writeln!(out).unwrap(); - writeln!(out, "{}", s.bold.apply_to("Warnings:")).unwrap(); - for check in &warnings { - write!(out, " • {}", check.name).unwrap(); - if let Some(ref rem) = check.remediation { - write!(out, " — {rem}").unwrap(); - } - writeln!(out).unwrap(); - } - } - } - - if !live { - writeln!(out).unwrap(); - writeln!(out, "Run with --live to probe service connectivity.").unwrap(); - } - - out - } -} - // --------------------------------------------------------------------------- // System dependency types and parsers // --------------------------------------------------------------------------- @@ -1141,7 +1010,8 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { } // Run pure checks - let report = DoctorReport { + let report = CheckReport { + title: "Arc Doctor".into(), checks: vec![ check_config(if config_exists { config_path } else { None }), check_system_deps(DEP_SPECS, &dep_results), @@ -1155,7 +1025,12 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { ], }; - print!("{}", report.render(&styles, verbose, live)); + let footer = if !live { + Some("Run with --live to probe service connectivity.") + } else { + None + }; + print!("{}", report.render(&styles, verbose, footer)); if report.has_errors() { 1 @@ -1172,162 +1047,6 @@ pub async fn run_doctor(verbose: bool, live: bool) -> i32 { mod tests { use super::*; - fn pass_check(name: &str) -> CheckResult { - CheckResult { - name: name.to_string(), - status: CheckStatus::Pass, - summary: "all good".to_string(), - details: vec![CheckDetail { - text: "everything is fine".to_string(), - }], - remediation: None, - } - } - - fn warning_check(name: &str) -> CheckResult { - CheckResult { - name: name.to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details: vec![CheckDetail { - text: "missing something".to_string(), - }], - remediation: Some("fix it".to_string()), - } - } - - fn error_check(name: &str) -> CheckResult { - CheckResult { - name: name.to_string(), - status: CheckStatus::Error, - summary: "broken".to_string(), - details: vec![CheckDetail { - text: "something is wrong".to_string(), - }], - remediation: Some("repair it".to_string()), - } - } - - // -- render: all-pass, no color -- - - #[test] - fn render_all_pass_no_color() { - let report = DoctorReport { - checks: vec![pass_check("Test")], - }; - let out = report.render(&Styles::new(false), false, false); - assert!(out.contains("[✓]")); - assert!(out.contains("All checks passed.")); - assert!(out.contains("Arc Doctor")); - } - - // -- render: warning footer -- - - #[test] - fn render_warning_footer() { - let report = DoctorReport { - checks: vec![warning_check("Optional")], - }; - let out = report.render(&Styles::new(false), false, false); - assert!(out.contains("[!]")); - assert!(out.contains("Doctor found issues in 1 category.")); - assert!(out.contains("Warnings:")); - assert!(out.contains("fix it")); - } - - // -- render: error footer -- - - #[test] - fn render_error_footer() { - let report = DoctorReport { - checks: vec![error_check("Broken")], - }; - let out = report.render(&Styles::new(false), false, false); - assert!(out.contains("[✗]")); - assert!(out.contains("Errors:")); - assert!(out.contains("repair it")); - } - - // -- render: verbose mode -- - - #[test] - fn render_verbose_shows_details() { - let report = DoctorReport { - checks: vec![pass_check("Verbose")], - }; - let out = report.render(&Styles::new(false), true, false); - assert!(out.contains("•")); - assert!(out.contains("everything is fine")); - } - - #[test] - fn render_default_hides_details() { - let report = DoctorReport { - checks: vec![pass_check("Verbose")], - }; - let out = report.render(&Styles::new(false), false, false); - assert!(!out.contains("everything is fine")); - } - - // -- render: color -- - - #[test] - fn render_color_pass_green() { - let report = DoctorReport { - checks: vec![pass_check("Color")], - }; - let out = report.render(&Styles::new(true), false, false); - assert!(out.contains("\x1b[32m")); // green - } - - #[test] - fn render_color_warning_yellow() { - let report = DoctorReport { - checks: vec![warning_check("Color")], - }; - let out = report.render(&Styles::new(true), false, false); - assert!(out.contains("\x1b[33m")); // yellow - } - - #[test] - fn render_color_error_red() { - let report = DoctorReport { - checks: vec![error_check("Color")], - }; - let out = report.render(&Styles::new(true), false, false); - assert!(out.contains("\x1b[31m")); // red - } - - // -- has_errors / issue_count -- - - #[test] - fn has_errors_false_for_warnings_only() { - let report = DoctorReport { - checks: vec![pass_check("OK"), warning_check("Warn")], - }; - assert!(!report.has_errors()); - } - - #[test] - fn has_errors_true_when_error_present() { - let report = DoctorReport { - checks: vec![pass_check("OK"), error_check("Broken")], - }; - assert!(report.has_errors()); - } - - #[test] - fn issue_count_counts_warnings_and_errors() { - let report = DoctorReport { - checks: vec![ - pass_check("OK"), - warning_check("Warn"), - error_check("Broken"), - ], - }; - assert_eq!(report.issue_count(), 2); - } - // -- check_config -- #[test] @@ -1669,17 +1388,6 @@ mod tests { .any(|d| d.text.contains("connection refused"))); } - // -- render: multiple issues -- - - #[test] - fn render_multiple_issues_pluralizes() { - let report = DoctorReport { - checks: vec![warning_check("A"), error_check("B")], - }; - let out = report.render(&Styles::new(false), false, false); - assert!(out.contains("2 categories")); - } - // -- parse_version -- #[test] diff --git a/crates/arc-util/src/check_report.rs b/crates/arc-util/src/check_report.rs new file mode 100644 index 000000000..c4540b561 --- /dev/null +++ b/crates/arc-util/src/check_report.rs @@ -0,0 +1,322 @@ +use std::fmt::Write; + +use crate::terminal::Styles; + +// --------------------------------------------------------------------------- +// Core types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum CheckStatus { + Pass, + Warning, + Error, +} + +#[derive(Debug, Clone)] +pub struct CheckDetail { + pub text: String, +} + +#[derive(Debug, Clone)] +pub struct CheckResult { + pub name: String, + pub status: CheckStatus, + pub summary: String, + pub details: Vec, + pub remediation: Option, +} + +pub struct CheckReport { + pub title: String, + pub checks: Vec, +} + +impl CheckReport { + pub fn has_errors(&self) -> bool { + self.checks.iter().any(|c| c.status == CheckStatus::Error) + } + + pub fn issue_count(&self) -> usize { + self.checks + .iter() + .filter(|c| matches!(c.status, CheckStatus::Warning | CheckStatus::Error)) + .count() + } + + pub fn render(&self, s: &Styles, verbose: bool, footer: Option<&str>) -> String { + let mut out = String::new(); + + writeln!(out, "{}", s.bold.apply_to(&self.title)).unwrap(); + writeln!(out).unwrap(); + + for check in &self.checks { + let (icon, color) = match check.status { + CheckStatus::Pass => ("[✓]", &s.green), + CheckStatus::Warning => ("[!]", &s.yellow), + CheckStatus::Error => ("[✗]", &s.red), + }; + + writeln!( + out, + " {} {} ({})", + color.apply_to(icon), + s.bold.apply_to(&check.name), + check.summary, + ) + .unwrap(); + + if verbose { + for detail in &check.details { + writeln!(out, " • {}", detail.text).unwrap(); + } + } + } + + let issues = self.issue_count(); + writeln!(out).unwrap(); + + if issues == 0 { + writeln!(out, "All checks passed.").unwrap(); + } else { + writeln!( + out, + "Found issues in {issues} {}.", + if issues == 1 { + "category" + } else { + "categories" + } + ) + .unwrap(); + + let errors: Vec<_> = self + .checks + .iter() + .filter(|c| c.status == CheckStatus::Error) + .collect(); + if !errors.is_empty() { + writeln!(out).unwrap(); + writeln!(out, "{}", s.bold.apply_to("Errors:")).unwrap(); + for check in &errors { + write!(out, " • {}", check.name).unwrap(); + if let Some(ref rem) = check.remediation { + write!(out, " — {rem}").unwrap(); + } + writeln!(out).unwrap(); + } + } + + let warnings: Vec<_> = self + .checks + .iter() + .filter(|c| c.status == CheckStatus::Warning) + .collect(); + if !warnings.is_empty() { + writeln!(out).unwrap(); + writeln!(out, "{}", s.bold.apply_to("Warnings:")).unwrap(); + for check in &warnings { + write!(out, " • {}", check.name).unwrap(); + if let Some(ref rem) = check.remediation { + write!(out, " — {rem}").unwrap(); + } + writeln!(out).unwrap(); + } + } + } + + if let Some(footer_text) = footer { + writeln!(out).unwrap(); + writeln!(out, "{footer_text}").unwrap(); + } + + out + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn pass_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Pass, + summary: "all good".to_string(), + details: vec![CheckDetail { + text: "everything is fine".to_string(), + }], + remediation: None, + } + } + + fn warning_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Warning, + summary: "not configured".to_string(), + details: vec![CheckDetail { + text: "missing something".to_string(), + }], + remediation: Some("fix it".to_string()), + } + } + + fn error_check(name: &str) -> CheckResult { + CheckResult { + name: name.to_string(), + status: CheckStatus::Error, + summary: "broken".to_string(), + details: vec![CheckDetail { + text: "something is wrong".to_string(), + }], + remediation: Some("repair it".to_string()), + } + } + + fn report(checks: Vec) -> CheckReport { + CheckReport { + title: "Test Report".into(), + checks, + } + } + + // -- render: all-pass, no color -- + + #[test] + fn render_all_pass_no_color() { + let r = report(vec![pass_check("Test")]); + let out = r.render(&Styles::new(false), false, None); + assert!(out.contains("[✓]")); + assert!(out.contains("All checks passed.")); + assert!(out.contains("Test Report")); + } + + // -- render: warning footer -- + + #[test] + fn render_warning_footer() { + let r = report(vec![warning_check("Optional")]); + let out = r.render(&Styles::new(false), false, None); + assert!(out.contains("[!]")); + assert!(out.contains("Found issues in 1 category.")); + assert!(out.contains("Warnings:")); + assert!(out.contains("fix it")); + } + + // -- render: error footer -- + + #[test] + fn render_error_footer() { + let r = report(vec![error_check("Broken")]); + let out = r.render(&Styles::new(false), false, None); + assert!(out.contains("[✗]")); + assert!(out.contains("Errors:")); + assert!(out.contains("repair it")); + } + + // -- render: verbose mode -- + + #[test] + fn render_verbose_shows_details() { + let r = report(vec![pass_check("Verbose")]); + let out = r.render(&Styles::new(false), true, None); + assert!(out.contains("•")); + assert!(out.contains("everything is fine")); + } + + #[test] + fn render_default_hides_details() { + let r = report(vec![pass_check("Verbose")]); + let out = r.render(&Styles::new(false), false, None); + assert!(!out.contains("everything is fine")); + } + + // -- render: color -- + + #[test] + fn render_color_pass_green() { + let r = report(vec![pass_check("Color")]); + let out = r.render(&Styles::new(true), false, None); + assert!(out.contains("\x1b[32m")); // green + } + + #[test] + fn render_color_warning_yellow() { + let r = report(vec![warning_check("Color")]); + let out = r.render(&Styles::new(true), false, None); + assert!(out.contains("\x1b[33m")); // yellow + } + + #[test] + fn render_color_error_red() { + let r = report(vec![error_check("Color")]); + let out = r.render(&Styles::new(true), false, None); + assert!(out.contains("\x1b[31m")); // red + } + + // -- has_errors / issue_count -- + + #[test] + fn has_errors_false_for_warnings_only() { + let r = report(vec![pass_check("OK"), warning_check("Warn")]); + assert!(!r.has_errors()); + } + + #[test] + fn has_errors_true_when_error_present() { + let r = report(vec![pass_check("OK"), error_check("Broken")]); + assert!(r.has_errors()); + } + + #[test] + fn issue_count_counts_warnings_and_errors() { + let r = report(vec![ + pass_check("OK"), + warning_check("Warn"), + error_check("Broken"), + ]); + assert_eq!(r.issue_count(), 2); + } + + // -- render: multiple issues -- + + #[test] + fn render_multiple_issues_pluralizes() { + let r = report(vec![warning_check("A"), error_check("B")]); + let out = r.render(&Styles::new(false), false, None); + assert!(out.contains("2 categories")); + } + + // -- render: footer text -- + + #[test] + fn render_footer_text_when_provided() { + let r = report(vec![pass_check("Test")]); + let out = r.render(&Styles::new(false), false, Some("Run with --live to probe.")); + assert!(out.contains("Run with --live to probe.")); + } + + #[test] + fn render_no_footer_when_none() { + let r = report(vec![pass_check("Test")]); + let out = r.render(&Styles::new(false), false, None); + assert!(!out.contains("--live")); + } + + // -- render: custom title -- + + #[test] + fn render_uses_custom_title() { + let r = CheckReport { + title: "My Custom Title".into(), + checks: vec![pass_check("Test")], + }; + let out = r.render(&Styles::new(false), false, None); + assert!(out.contains("My Custom Title")); + } +} diff --git a/crates/arc-util/src/lib.rs b/crates/arc-util/src/lib.rs index b36d3c5a1..bab7da24d 100644 --- a/crates/arc-util/src/lib.rs +++ b/crates/arc-util/src/lib.rs @@ -1,2 +1,3 @@ +pub mod check_report; pub mod redact; pub mod terminal; diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index f1f33bb64..1ea40693e 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -1107,7 +1107,7 @@ fn print_final_output(logs_dir: &std::path::Path, styles: &Styles) { /// /// Boots the sandbox (init + cleanup), checks LLM provider availability, /// resolves the model/provider through the full precedence chain, and prints -/// a structured report. +/// a styled check report. async fn run_preflight( graph: &crate::graph::types::Graph, run_cfg: &Option, @@ -1117,9 +1117,41 @@ async fn run_preflight( sandbox_provider: SandboxProvider, styles: &'static Styles, ) -> anyhow::Result<()> { - let mut errors: Vec = Vec::new(); + use arc_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckStatus}; - // 1. Sandbox boot check + let mut checks: Vec = Vec::new(); + + // 1. Workflow metadata (always Pass) + let setup_command_count = run_cfg + .as_ref() + .and_then(|c| c.setup.as_ref()) + .map_or(0, |s| s.commands.len()); + + let (model, provider) = resolve_model_provider( + args.model.as_deref(), + args.provider.as_deref(), + run_cfg.as_ref(), + run_defaults, + graph, + ); + + checks.push(CheckResult { + name: "Workflow".into(), + status: CheckStatus::Pass, + summary: graph.name.clone(), + details: vec![ + CheckDetail { text: format!("Nodes: {}", graph.nodes.len()) }, + CheckDetail { text: format!("Edges: {}", graph.edges.len()) }, + CheckDetail { text: format!("Goal: {}", graph.goal()) }, + CheckDetail { text: format!("Model: {model}") }, + CheckDetail { text: format!("Provider: {}", provider.as_deref().unwrap_or("anthropic")) }, + CheckDetail { text: format!("Setup commands: {setup_command_count}") }, + CheckDetail { text: format!("Git clean: {git_clean}") }, + ], + remediation: None, + }); + + // 2. Sandbox boot check let original_cwd = std::env::current_dir()?; let daytona_config = resolve_daytona_config(run_cfg.as_ref(), run_defaults); @@ -1146,100 +1178,132 @@ async fn run_preflight( } }; - let sandbox_ready = match sandbox_result { + let sandbox_ok = match sandbox_result { Ok(sandbox) => match sandbox.initialize().await { Ok(()) => { let _ = sandbox.cleanup().await; true } Err(e) => { - errors.push(format!("Sandbox init failed: {e}")); let _ = sandbox.cleanup().await; + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }], + remediation: Some(format!("Sandbox init failed: {e}")), + }); false } }, Err(e) => { - errors.push(e); + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Error, + summary: "failed".into(), + details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }], + remediation: Some(e), + }); false } }; - // 2. LLM client check - let (llm_available, llm_providers) = match arc_llm::client::Client::from_env().await { + if sandbox_ok { + checks.push(CheckResult { + name: "Sandbox".into(), + status: CheckStatus::Pass, + summary: sandbox_provider.to_string(), + details: vec![CheckDetail { text: format!("Provider: {sandbox_provider}") }], + remediation: None, + }); + } + + // 3. LLM client check + let llm_ok = match arc_llm::client::Client::from_env().await { Ok(c) => { - let names = c + let names: Vec = c .provider_names() .iter() .map(|s| s.to_string()) - .collect::>(); + .collect(); if names.is_empty() { - errors.push("No LLM providers configured (no API keys found)".to_string()); - (false, names) + checks.push(CheckResult { + name: "LLM providers".into(), + status: CheckStatus::Error, + summary: "no API keys".into(), + details: vec![], + remediation: Some("Set at least one LLM provider API key".into()), + }); + false } else { - (true, names) + checks.push(CheckResult { + name: "LLM providers".into(), + status: CheckStatus::Pass, + summary: names.join(", "), + details: vec![], + remediation: None, + }); + true } } Err(e) => { - errors.push(format!("LLM client init failed: {e}")); - (false, Vec::new()) + checks.push(CheckResult { + name: "LLM providers".into(), + status: CheckStatus::Error, + summary: "initialization failed".into(), + details: vec![], + remediation: Some(format!("LLM client init failed: {e}")), + }); + false } }; - // 3. Model/provider resolution - let (model, provider) = resolve_model_provider( - args.model.as_deref(), - args.provider.as_deref(), - run_cfg.as_ref(), - run_defaults, - graph, - ); - // 4. Provider parse check - let provider_valid = if let Some(ref p) = provider { + let provider_ok = if let Some(ref p) = provider { match p.parse::() { - Ok(_) => true, + Ok(_) => { + checks.push(CheckResult { + name: "Provider".into(), + status: CheckStatus::Pass, + summary: p.clone(), + details: vec![], + remediation: None, + }); + true + } Err(e) => { - errors.push(format!("Invalid provider \"{p}\": {e}")); + checks.push(CheckResult { + name: "Provider".into(), + status: CheckStatus::Error, + summary: p.clone(), + details: vec![], + remediation: Some(format!("Invalid provider \"{p}\": {e}")), + }); false } } } else { - true // None means default (Anthropic), which is valid + checks.push(CheckResult { + name: "Provider".into(), + status: CheckStatus::Pass, + summary: "anthropic".into(), + details: vec![], + remediation: None, + }); + true }; - // 5. Count setup commands for display - let setup_command_count = run_cfg - .as_ref() - .and_then(|c| c.setup.as_ref()) - .map_or(0, |s| s.commands.len()); + // 5. Render report + let report = CheckReport { + title: "Run Preflight".into(), + checks, + }; - // 6. Print structured report to stdout - println!("workflow={}", graph.name); - println!("nodes={}", graph.nodes.len()); - println!("edges={}", graph.edges.len()); - println!("goal={}", graph.goal()); - println!("sandbox={sandbox_provider}"); - println!("sandbox_ready={sandbox_ready}"); - println!("git_clean={git_clean}"); - println!("llm_available={llm_available}"); - println!("llm_providers={}", llm_providers.join(",")); - println!("model={model}"); - println!("provider={}", provider.as_deref().unwrap_or("anthropic")); - println!("provider_valid={provider_valid}"); - println!("setup_commands={setup_command_count}"); + print!("{}", report.render(styles, true, None)); - // 7. Print warnings/errors to stderr - for err in &errors { - eprintln!("{}: {err}", styles.red.apply_to("error"),); - } - - // 8. Final verdict - let ok = sandbox_ready && llm_available && provider_valid; - if ok { - eprintln!("\n{}", styles.bold_green.apply_to("Preflight: OK"),); + if sandbox_ok && llm_ok && provider_ok { Ok(()) } else { - eprintln!("\n{}", styles.bold_red.apply_to("Preflight: FAIL"),); std::process::exit(1); } }