use agentcarousel_fixtures::{load_fixture_value, validate_fixture_value, SchemaLocation};
use clap::Parser;
use console::style;
use serde::Serialize;
use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use std::path::{Path, PathBuf};
use super::config::{resolve_schema_path, ResolvedConfig};
use super::exit_codes::ExitCode;
use super::fixture_utils::{collect_fixture_paths_with_ignore, is_kebab_case};
use super::GlobalOptions;
const AGENTCAROUSEL_IGNORE: &str = ".agentcarousel-ignore";
#[derive(Debug, Parser)]
#[command(
after_help = "Examples:\n agc validate fixtures/skills/customer-support.yaml\n agc validate fixtures/ --strict\n agc validate --format sarif > results.sarif # GitHub code scanning\n agc validate # scans . respecting .agentcarousel-ignore"
)]
pub struct ValidateArgs {
#[arg(value_name = "PATHS")]
paths: Vec<PathBuf>,
#[arg(long)]
pub config: Option<PathBuf>,
#[arg(short = 's', long)]
schema: Option<PathBuf>,
#[arg(short = 'x', long)]
strict: bool,
#[arg(short = 'f', long)]
format: Option<String>,
}
#[derive(Debug, Serialize)]
struct ValidationReport {
path: String,
errors: Vec<String>,
warnings: Vec<String>,
}
#[derive(Debug, Serialize)]
struct OutputMessage {
file: String,
line: u32,
col: u32,
level: String,
message: String,
}
#[derive(Debug, Default, Serialize)]
struct AtfFileHints {
cases: usize,
cases_with_negative_tag: usize,
risk_tier: Option<String>,
data_handling: Option<String>,
certification_track: Option<String>,
declares_bundle_id: bool,
}
#[derive(Debug, Default, Serialize)]
struct AtfSummary {
fixture_files_loaded: usize,
total_cases: usize,
cases_with_negative_tag: usize,
fixtures_declaring_bundle_id: usize,
risk_tier: BTreeMap<String, usize>,
data_handling: BTreeMap<String, usize>,
certification_track: BTreeMap<String, usize>,
}
#[derive(Debug, Serialize)]
struct ValidateJsonBody<'a> {
messages: Vec<OutputMessage>,
atf_summary: &'a AtfSummary,
}
pub fn run_validate(args: ValidateArgs, config: &ResolvedConfig, globals: &GlobalOptions) -> i32 {
let mut reports = Vec::new();
let mut has_errors = false;
let mut has_warnings = false;
let format = resolve_format(args.format.as_deref(), &config.output.format);
let strict = args.strict || config.validate.strict;
let inputs = fixture_scan_roots(&args.paths);
let ignore_file = Path::new(AGENTCAROUSEL_IGNORE)
.exists()
.then_some(Path::new(AGENTCAROUSEL_IGNORE));
let mut atf_rows = Vec::new();
for path in collect_fixture_paths_with_ignore(&inputs, ignore_file) {
match validate_path(&path, args.schema.as_deref(), config) {
Ok((report, hints)) => {
has_errors |= !report.errors.is_empty();
has_warnings |= !report.warnings.is_empty();
atf_rows.push(hints);
reports.push(report);
}
Err(err) => {
has_errors = true;
reports.push(ValidationReport {
path: path.display().to_string(),
errors: vec![err],
warnings: vec![],
});
}
}
}
let atf_summary = summarize_atf(&atf_rows);
if !globals.quiet {
output_reports(&format, &reports, &atf_summary);
}
if has_errors || (strict && has_warnings) {
return ExitCode::ValidationFailed.as_i32();
}
ExitCode::Ok.as_i32()
}
fn fixture_scan_roots(paths: &[PathBuf]) -> Vec<PathBuf> {
if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths.to_vec()
}
}
fn validate_path(
path: &Path,
schema_path: Option<&Path>,
config: &ResolvedConfig,
) -> Result<(ValidationReport, AtfFileHints), String> {
let value = load_fixture_value(path).map_err(|err| err.to_string())?;
let schema_location = schema_path
.map(|path| SchemaLocation::Path(path.to_path_buf()))
.unwrap_or_else(|| SchemaLocation::Path(resolve_schema_path(config)));
let mut errors = Vec::new();
let mut warnings = Vec::new();
warn_pem_material(&value, &mut warnings);
let schema_errors =
validate_fixture_value(&value, schema_location).map_err(|err| err.to_string())?;
for issue in schema_errors {
errors.push(issue.to_string());
}
if let Some(object) = value.as_object() {
let known_keys: HashSet<&str> = [
"schema_version",
"skill_or_agent",
"defaults",
"cases",
"bundle_id",
"bundle_version",
"certification_track",
"risk_tier",
"data_handling",
]
.into_iter()
.collect();
for key in object.keys() {
if !known_keys.contains(key.as_str()) {
warnings.push(format!("unknown top-level key: {key}"));
}
}
}
if let Some(skill_or_agent) = value.get("skill_or_agent").and_then(|value| value.as_str()) {
if !is_kebab_case(skill_or_agent) {
errors.push("skill_or_agent must be kebab-case".to_string());
}
if let Some(cases) = value.get("cases").and_then(|value| value.as_array()) {
for case in cases {
if let Some(case_id) = case.get("id").and_then(|id| id.as_str()) {
let prefix = format!("{skill_or_agent}/");
if !case_id.starts_with(&prefix) {
errors.push(format!("case id must start with \"{prefix}\": {case_id}"));
}
} else {
errors.push("case id is required".to_string());
}
if let Some(config) = case
.get("evaluator_config")
.and_then(|value| value.as_object())
{
if let Some(path) = config.get("golden_path").and_then(|value| value.as_str()) {
if let Err(message) = ensure_safe_relative("golden_path", path) {
errors.push(message);
}
}
if let Some(cmds) = config.get("process_cmd").and_then(|value| value.as_array())
{
for cmd in cmds {
if let Some(cmd_str) = cmd.as_str() {
if let Err(message) = ensure_safe_relative("process_cmd", cmd_str) {
errors.push(message);
}
}
}
}
}
}
} else {
warnings.push("cases array is empty".to_string());
}
} else {
errors.push("skill_or_agent is required".to_string());
}
let hints = atf_hints_from_value(&value);
Ok((
ValidationReport {
path: path.display().to_string(),
errors,
warnings,
},
hints,
))
}
fn warn_pem_material(value: &Value, warnings: &mut Vec<String>) {
let mut found = false;
walk_string_values(value, &mut |s: &str| {
if found {
return;
}
if s.contains("BEGIN ") && s.contains("PRIVATE KEY") {
warnings.push(
"possible PEM private key material in fixture strings (warning only; remove secrets from fixtures)"
.to_string(),
);
found = true;
}
});
}
fn walk_string_values(value: &Value, f: &mut impl FnMut(&str)) {
match value {
Value::Object(map) => {
for v in map.values() {
walk_string_values(v, f);
}
}
Value::Array(arr) => {
for v in arr {
walk_string_values(v, f);
}
}
Value::String(s) => f(s),
_ => {}
}
}
fn atf_hints_from_value(value: &Value) -> AtfFileHints {
let mut hints = AtfFileHints::default();
let Some(obj) = value.as_object() else {
return hints;
};
hints.declares_bundle_id = obj
.get("bundle_id")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty());
hints.risk_tier = obj
.get("risk_tier")
.and_then(|v| v.as_str())
.map(str::to_string);
hints.data_handling = obj
.get("data_handling")
.and_then(|v| v.as_str())
.map(str::to_string);
hints.certification_track = obj
.get("certification_track")
.and_then(|v| v.as_str())
.map(str::to_string);
let Some(cases) = obj.get("cases").and_then(|c| c.as_array()) else {
return hints;
};
hints.cases = cases.len();
for case in cases {
let Some(case_obj) = case.as_object() else {
continue;
};
let tags = case_obj
.get("tags")
.and_then(|t| t.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(str::to_lowercase)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if tags.iter().any(|t| t == "negative" || t == "smoke") {
hints.cases_with_negative_tag += 1;
}
}
hints
}
fn summarize_atf(rows: &[AtfFileHints]) -> AtfSummary {
let mut summary = AtfSummary {
fixture_files_loaded: rows.len(),
..AtfSummary::default()
};
for row in rows {
summary.total_cases += row.cases;
summary.cases_with_negative_tag += row.cases_with_negative_tag;
if row.declares_bundle_id {
summary.fixtures_declaring_bundle_id += 1;
}
if let Some(ref tier) = row.risk_tier {
*summary.risk_tier.entry(tier.clone()).or_insert(0) += 1;
} else {
*summary.risk_tier.entry("unset".to_string()).or_insert(0) += 1;
}
if let Some(ref dh) = row.data_handling {
*summary.data_handling.entry(dh.clone()).or_insert(0) += 1;
} else {
*summary
.data_handling
.entry("unset".to_string())
.or_insert(0) += 1;
}
if let Some(ref ct) = row.certification_track {
*summary.certification_track.entry(ct.clone()).or_insert(0) += 1;
} else {
*summary
.certification_track
.entry("unset".to_string())
.or_insert(0) += 1;
}
}
summary
}
fn resolve_format(value: Option<&str>, default_format: &str) -> String {
value.unwrap_or(default_format).to_string()
}
fn ensure_safe_relative(label: &str, value: &str) -> Result<(), String> {
let path = Path::new(value);
if path.is_absolute() {
return Err(format!("{label} must be a relative path: {value}"));
}
if path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
return Err(format!("{label} must not contain '..': {value}"));
}
Ok(())
}
fn output_reports(format: &str, reports: &[ValidationReport], atf_summary: &AtfSummary) {
match format {
"json" => {
let messages = collect_messages(reports);
let body = ValidateJsonBody {
messages,
atf_summary,
};
let payload =
serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{\"messages\":[]}".into());
println!("{payload}");
}
"sarif" => {
let payload = build_sarif(reports);
println!("{payload}");
}
_ => print_validate_terminal(reports, atf_summary),
}
}
fn build_sarif(reports: &[ValidationReport]) -> String {
let tool = serde_json::json!({
"driver": {
"name": "agentcarousel",
"version": env!("CARGO_PKG_VERSION"),
"informationUri": "https://agentcarousel.com",
"rules": [
{ "id": "AC001", "name": "SchemaError", "shortDescription": { "text": "Fixture schema validation error" } },
{ "id": "AC002", "name": "SchemaWarning", "shortDescription": { "text": "Fixture schema validation warning" } }
]
}
});
let mut results: Vec<serde_json::Value> = Vec::new();
for report in reports {
for error in &report.errors {
results.push(serde_json::json!({
"ruleId": "AC001",
"level": "error",
"message": { "text": error },
"locations": [{ "physicalLocation": { "artifactLocation": { "uri": report.path } } }]
}));
}
for warning in &report.warnings {
results.push(serde_json::json!({
"ruleId": "AC002",
"level": "warning",
"message": { "text": warning },
"locations": [{ "physicalLocation": { "artifactLocation": { "uri": report.path } } }]
}));
}
}
let sarif = serde_json::json!({
"$schema": "https://json.schemastore.org/sarif-2.1.0.json",
"version": "2.1.0",
"runs": [{ "tool": tool, "results": results }]
});
serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| "{}".into())
}
fn print_validate_terminal(reports: &[ValidationReport], atf_summary: &AtfSummary) {
let n = reports.len();
let plural = if n == 1 { "fixture" } else { "fixtures" };
println!(
"🎠 AgentCarousel v{} · validate · {} {}",
env!("CARGO_PKG_VERSION"),
n,
plural
);
println!();
println!(
"{}",
style("Checking JSON Schema, kebab-case ids, case id prefixes, and safe paths").dim()
);
println!();
let mut total_errors = 0usize;
let mut total_warnings = 0usize;
for report in reports {
total_errors += report.errors.len();
total_warnings += report.warnings.len();
let path_label = report.path.as_str();
if report.errors.is_empty() && report.warnings.is_empty() {
println!(" ✅ PASS {}", style(path_label).green());
continue;
}
if !report.errors.is_empty() {
println!(" ❌ FAIL {}", style(path_label).red());
for err in &report.errors {
println!(" › {}", style(err.as_str()).dim());
}
} else {
println!(
" {} WARN {}",
style("⚠").yellow(),
style(path_label).yellow()
);
}
for warn in &report.warnings {
println!(
" › {} {}",
style("warn").yellow(),
style(warn.as_str()).dim()
);
}
}
println!();
println!(" ──────────────────────────────────────────────────────");
let err_word = if total_errors == 1 { "error" } else { "errors" };
let warn_word = if total_warnings == 1 {
"warning"
} else {
"warnings"
};
println!(
" Results {} {} · {} {}",
total_errors, err_word, total_warnings, warn_word
);
print_validate_atf_footer(atf_summary);
if total_errors == 0 {
if total_warnings == 0 {
println!(
" {}",
style("Validation: OK — fixtures pass checks").green()
);
} else {
println!(
" {}",
style("Validation: passed with warnings (use --strict to fail)").yellow()
);
}
} else {
println!(" {}", style("Validation: failed — fix errors above").red());
}
println!(" ──────────────────────────────────────────────────────");
}
fn print_validate_atf_footer(s: &AtfSummary) {
if s.fixture_files_loaded == 0 {
return;
}
println!(
" Coverage {} file(s) · {} case(s) · {} with smoke/negative tag",
s.fixture_files_loaded, s.total_cases, s.cases_with_negative_tag
);
if s.fixtures_declaring_bundle_id > 0 {
println!(
" {} fixture(s) declare bundle_id",
s.fixtures_declaring_bundle_id
);
}
let mut tier_parts: Vec<String> = s
.risk_tier
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
if !tier_parts.is_empty() {
tier_parts.sort();
println!(" Risk tier: {}", tier_parts.join(", "));
}
let mut dh_parts: Vec<String> = s
.data_handling
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
if !dh_parts.is_empty() {
dh_parts.sort();
println!(" Data handling: {}", dh_parts.join(", "));
}
let mut ct_parts: Vec<String> = s
.certification_track
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
if !ct_parts.is_empty() {
ct_parts.sort();
println!(" Certification track: {}", ct_parts.join(", "));
}
}
fn collect_messages(reports: &[ValidationReport]) -> Vec<OutputMessage> {
let mut messages = Vec::new();
for report in reports {
for error in &report.errors {
messages.push(OutputMessage {
file: report.path.clone(),
line: 1,
col: 1,
level: "ERROR".to_string(),
message: error.clone(),
});
}
for warning in &report.warnings {
messages.push(OutputMessage {
file: report.path.clone(),
line: 1,
col: 1,
level: "WARN".to_string(),
message: warning.clone(),
});
}
}
messages
}