use super::io::{print_markdown_report, print_text_report};
use crate::cli::args::CoverageArgs;
use anyhow::{Context, Result};
pub(super) async fn cmd_coverage_legacy(args: CoverageArgs) -> Result<i32> {
let trace_file = match args.trace_file.as_ref() {
Some(path) => path,
None => {
eprintln!(
"Measurement error: --trace-file/--traces is required when --input is not used"
);
return Ok(crate::exit_codes::EXIT_CONFIG_ERROR);
}
};
let (policy_path, suite_name, config_fingerprint) = if let Some(p) = args.policy {
let suite = p
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("manual_policy")
.to_string();
let fp = assay_core::baseline::compute_config_fingerprint(&p);
(p, suite, fp)
} else {
let cfg = assay_core::config::load_config(&args.config, false, false)
.context("failed to load config (and no --policy provided)")?;
let mut policy_paths = std::collections::HashSet::new();
for test in &cfg.tests {
if let Some(path) = test.expected.get_policy_path() {
policy_paths.insert(path.to_string());
}
}
if policy_paths.is_empty() {
anyhow::bail!("No policy provided via --policy, and none referenced in config.");
}
if policy_paths.len() > 1 {
eprintln!(
"warning: multiple policies found in config: {:?}. Using the first one.",
policy_paths
);
}
let rel = policy_paths.iter().next().unwrap();
let config_dir = args.config.parent().unwrap_or(std::path::Path::new("."));
let policy_path = config_dir.join(rel);
let fp = assay_core::baseline::compute_config_fingerprint(&args.config);
(policy_path, cfg.suite, fp)
};
let policy_content = tokio::fs::read_to_string(&policy_path)
.await
.with_context(|| format!("failed to read policy file: {}", policy_path.display()))?;
let mut policy_v2: assay_core::mcp::policy::McpPolicy =
serde_yaml::from_str(&policy_content).context("failed to parse policy yaml")?;
policy_v2.normalize_legacy_shapes();
if !policy_v2.constraints.is_empty() {
policy_v2.migrate_constraints_to_schemas();
}
let policy: assay_core::model::Policy = match serde_yaml::from_str(&policy_content) {
Ok(p) => p,
Err(_) => {
assay_core::model::Policy {
version: policy_v2.version.clone(),
name: policy_v2.name.clone(),
metadata: None,
tools: assay_core::model::ToolsPolicy {
allow: policy_v2.tools.allow.clone(),
deny: policy_v2.tools.deny.clone(),
require_args: None,
arg_constraints: None,
},
sequences: vec![],
aliases: std::collections::HashMap::new(),
on_error: assay_core::on_error::ErrorPolicy::default(),
}
}
};
let file_content: String = tokio::fs::read_to_string(trace_file)
.await
.context("failed to read trace file")?;
let mut trace_records = Vec::new();
policy_v2.compile_all_schemas();
let mut state = assay_core::mcp::policy::PolicyState::default();
let mut violations = Vec::new();
let mut warnings = Vec::new();
let mut events_by_id: std::collections::HashMap<String, Vec<serde_json::Value>> =
std::collections::HashMap::new();
for line in file_content.lines() {
if line.trim().is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(line).context("invalid jsonl")?;
let id_val = v
.get("test_id")
.or_else(|| v.get("episode_id"))
.or_else(|| v.get("run_id"))
.or_else(|| v.get("id"));
let id = if let Some(id_s) = id_val.and_then(|s| s.as_str()) {
id_s.to_string()
} else {
"unknown".to_string()
};
events_by_id.entry(id).or_default().push(v);
}
for (id, events) in events_by_id {
let mut tools_called = Vec::new();
let rules_triggered = std::collections::HashSet::new();
for event in events {
if let Some(typ) = event.get("type").and_then(|s| s.as_str()) {
if typ == "call_tool" {
let tool_opt = event
.get("tool_name")
.or_else(|| event.get("tool"))
.and_then(|s| s.as_str());
if let Some(tool) = tool_opt {
let tool_name = tool.to_string();
tools_called.push(tool_name.clone());
let args_default = serde_json::json!({});
let args = event
.get("arguments")
.or_else(|| event.get("input")) .unwrap_or(&args_default);
let decision = policy_v2.evaluate(&tool_name, args, &mut state, None);
match decision {
assay_core::mcp::policy::PolicyDecision::Allow => {}
assay_core::mcp::policy::PolicyDecision::AllowWithWarning {
code,
reason,
..
} => {
warnings.push(assay_core::coverage::PolicyWarning {
trace_id: id.clone(),
tool: tool_name.clone(),
warning_code: code,
reason,
});
}
assay_core::mcp::policy::PolicyDecision::Deny {
code, reason, ..
} => {
violations.push(assay_core::coverage::PolicyViolation {
trace_id: id.clone(),
tool: tool_name.clone(),
error_code: code,
reason,
});
}
}
}
}
}
if let Some(tools) = event.get("tools").and_then(|v| v.as_array()) {
for t in tools {
if let Some(s) = t.as_str() {
tools_called.push(s.to_string());
}
}
}
}
if !tools_called.is_empty() {
trace_records.push(assay_core::coverage::TraceRecord {
trace_id: id,
tools_called,
rules_triggered,
});
}
}
if trace_records.is_empty() {
eprintln!("warning: no tool calls found in trace file");
}
let analyzer = assay_core::coverage::CoverageAnalyzer::from_policy(&policy);
let mut report = analyzer.analyze(&trace_records, args.min_coverage);
report.policy_violations = violations;
report.policy_warnings = warnings;
match args.format.as_str() {
"json" => {
println!("{}", serde_json::to_string_pretty(&report)?);
}
"markdown" => {
print_markdown_report(&report);
}
"github" => {
print_markdown_report(&report);
}
_ => {
print_text_report(&report);
}
}
let mut clean_pass = true;
if let Some(export_path) = args.export_baseline {
let git_info = crate::cli::commands::baseline::capture_git_info();
let baseline = assay_core::baseline::Baseline::from_coverage_report(
&report,
suite_name.clone(),
config_fingerprint.clone(),
git_info,
);
baseline
.save(&export_path)
.context("failed to save baseline")?;
eprintln!("Exported baseline to {}", export_path.display());
}
if let Some(baseline_path) = args.baseline {
let baseline = assay_core::baseline::Baseline::load(&baseline_path)
.context("failed to load baseline for comparison")?;
let candidate = assay_core::baseline::Baseline::from_coverage_report(
&report,
suite_name.clone(),
config_fingerprint.clone(),
None, );
let diff = baseline.diff(&candidate);
if !diff.regressions.is_empty() {
eprintln!("\n❌ REGRESSION DETECTED against baseline:");
for r in &diff.regressions {
eprintln!(
" - {} metric '{}': {:.2}% -> {:.2}% (delta: {:.2}%)",
r.test_id, r.metric, r.baseline_score, r.candidate_score, r.delta
);
}
clean_pass = false;
} else {
eprintln!("\n✅ No regression against baseline.");
}
}
if !report.policy_violations.is_empty() {
eprintln!("\n🚨 ERROR: Policy Violations Detected in Traces!");
for v in &report.policy_violations {
eprintln!(
" - [{}][{}] {} ({})",
v.trace_id, v.tool, v.reason, v.error_code
);
}
clean_pass = false;
}
if !report.policy_warnings.is_empty() {
eprintln!("\n⚠️ Policy Warnings:");
for w in &report.policy_warnings {
eprintln!(
" - [{}][{}] {} ({})",
w.trace_id, w.tool, w.reason, w.warning_code
);
}
}
if !report.high_risk_gaps.is_empty() {
eprintln!("\n🚨 ERROR: High Risk Gaps Detected!");
eprintln!("The following DENY-listed tools were not tested:");
for gap in &report.high_risk_gaps {
eprintln!(" - {}", gap.tool);
}
clean_pass = false;
}
if !report.meets_threshold {
eprintln!(
"\n❌ Minimum coverage not met ({:.1}% < {:.1}%)",
report.overall_coverage_pct, report.threshold
);
clean_pass = false;
}
if clean_pass {
Ok(crate::exit_codes::OK)
} else {
Ok(crate::exit_codes::TEST_FAILED)
}
}