Extract CheckReport into arc-util and use it for preflight output

Move CheckReport, CheckResult, CheckDetail, and CheckStatus types from
arc-cli doctor into arc-util so they can be reused. Refactor the workflow
run_preflight to render a styled check report instead of ad-hoc key=value
output and separate stderr errors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-04 11:52:58 -05:00
parent d283ece683
commit 289451e78a
4 changed files with 453 additions and 358 deletions

View file

@ -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<CheckDetail>,
pub remediation: Option<String>,
}
pub struct DoctorReport {
pub checks: Vec<CheckResult>,
}
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]

View file

@ -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<CheckDetail>,
pub remediation: Option<String>,
}
pub struct CheckReport {
pub title: String,
pub checks: Vec<CheckResult>,
}
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<CheckResult>) -> 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"));
}
}

View file

@ -1,2 +1,3 @@
pub mod check_report;
pub mod redact;
pub mod terminal;

View file

@ -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<run_config::WorkflowRunConfig>,
@ -1117,9 +1117,41 @@ async fn run_preflight(
sandbox_provider: SandboxProvider,
styles: &'static Styles,
) -> anyhow::Result<()> {
let mut errors: Vec<String> = Vec::new();
use arc_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckStatus};
// 1. Sandbox boot check
let mut checks: Vec<CheckResult> = 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<String> = c
.provider_names()
.iter()
.map(|s| s.to_string())
.collect::<Vec<_>>();
.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::<Provider>() {
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);
}
}