mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
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:
parent
d283ece683
commit
289451e78a
4 changed files with 453 additions and 358 deletions
|
|
@ -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]
|
||||
|
|
|
|||
322
crates/arc-util/src/check_report.rs
Normal file
322
crates/arc-util/src/check_report.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod check_report;
|
||||
pub mod redact;
|
||||
pub mod terminal;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue