use apimock_config::{ConfigError, Severity, Workspace, WorkspaceError};
use crate::cmd::envelope::{self, ErrorKind};
fn error_kind_for_load_failure(e: &WorkspaceError) -> ErrorKind {
match e {
WorkspaceError::InvalidRoot { .. } => ErrorKind::ConfigUnreadable,
WorkspaceError::Config(ConfigError::ConfigRead { .. }) => ErrorKind::ConfigUnreadable,
WorkspaceError::Config(ConfigError::PathResolve { .. }) => ErrorKind::ConfigUnreadable,
WorkspaceError::Config(ConfigError::ConfigParse { .. }) => ErrorKind::ConfigInvalid,
WorkspaceError::Config(ConfigError::Validation) => ErrorKind::ConfigInvalid,
WorkspaceError::Config(ConfigError::RuleSet(_)) => ErrorKind::ConfigInvalid,
}
}
#[derive(PartialEq, Eq)]
pub enum Format {
Text,
Json,
}
pub struct ValidateArgs {
pub config_path: String,
pub strict: bool,
pub quiet: bool,
pub json: bool,
pub format: Option<Format>,
}
const CONFIG_NAMES: &[&str] = &["--config", "-c"];
const STRICT_FLAG: &str = "--strict";
const QUIET_FLAG: &str = "--quiet";
const JSON_FLAG: &str = "--json";
const FORMAT_FLAG: &str = "--format";
const JSON_DEPRECATION_WARNING: &str = "apimock validate: --json is deprecated and will be removed in 6.0.0.\n Use --format json, which emits the new response envelope.";
impl ValidateArgs {
pub fn parse(args: &[String]) -> Result<Self, String> {
let config_path = super::match_test::flag_value(args, CONFIG_NAMES)
.ok_or_else(|| "missing required flag --config / -c".to_owned())?;
let json = args.iter().any(|a| a == JSON_FLAG);
let format_raw = super::match_test::flag_value(args, &[FORMAT_FLAG]);
if json && format_raw.is_some() {
return Err(
"--json and --format cannot be used together; --format json is --json's replacement"
.to_owned(),
);
}
let format = match format_raw.as_deref() {
None => None,
Some("text") => Some(Format::Text),
Some("json") => Some(Format::Json),
Some(other) => {
return Err(format!(
"invalid value for --format: '{}' (expected 'text' or 'json')",
other
));
}
};
Ok(Self {
config_path,
strict: args.iter().any(|a| a == STRICT_FLAG),
quiet: args.iter().any(|a| a == QUIET_FLAG),
json,
format,
})
}
}
fn diagnostics_json(report: &apimock_config::ValidationReport) -> serde_json::Value {
let items: Vec<serde_json::Value> = report
.diagnostics
.iter()
.map(|d| {
serde_json::json!({
"severity": format!("{:?}", d.severity).to_lowercase(),
"message": d.message,
"node_id": d.node_id.map(|n| n.0.to_string()),
"file": d.file.as_ref().map(|p| p.to_string_lossy().into_owned()),
})
})
.collect();
serde_json::Value::Array(items)
}
pub fn run(args: &[String]) -> i32 {
let parsed = match ValidateArgs::parse(args) {
Ok(a) => a,
Err(e) => {
eprintln!("apimock validate: {}", e);
eprintln!(
"Usage: apimock validate --config <apimock.toml> [--strict] [--quiet] [--json] [--format text|json]"
);
return 2;
}
};
let is_envelope = parsed.format == Some(Format::Json);
if parsed.json {
eprintln!("{}", JSON_DEPRECATION_WARNING);
}
let ws = match Workspace::load(parsed.config_path.clone().into()) {
Ok(ws) => ws,
Err(e) => {
if is_envelope {
let envelope = envelope::err(
error_kind_for_load_failure(&e),
format!("failed to load config: {}", e),
);
println!(
"{}",
serde_json::to_string_pretty(&envelope).unwrap_or_default()
);
} else if !parsed.quiet {
eprintln!("apimock validate: failed to load config: {}", e);
}
return 2;
}
};
let report = ws.validate();
let snap = ws.snapshot();
let rule_set_count = snap.routes.rule_sets.len();
let rule_count: usize = snap.routes.rule_sets.iter().map(|rs| rs.rules.len()).sum();
let error_count = report
.diagnostics
.iter()
.filter(|d| matches!(d.severity, Severity::Error))
.count();
let warning_count = report
.diagnostics
.iter()
.filter(|d| matches!(d.severity, Severity::Warning))
.count();
let has_errors = error_count > 0;
let has_warnings = warning_count > 0;
if parsed.json {
println!(
"{}",
serde_json::to_string_pretty(&diagnostics_json(&report)).unwrap_or_default()
);
} else if is_envelope {
let result = serde_json::json!({
"diagnostics": diagnostics_json(&report),
"summary": {
"errors": error_count,
"warnings": warning_count,
"rule_sets": rule_set_count,
"rules": rule_count,
},
});
println!(
"{}",
serde_json::to_string_pretty(&envelope::ok(result)).unwrap_or_default()
);
} else if !parsed.quiet {
for d in &report.diagnostics {
let tag = match d.severity {
Severity::Error => "[ERROR]",
Severity::Warning => "[WARNING]",
Severity::Info => "[INFO]",
};
let location = d
.file
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|| parsed.config_path.clone());
println!("{}: {} {}", location, tag, d.message);
}
}
if has_errors || (parsed.strict && has_warnings) {
if !is_envelope && !parsed.quiet {
eprintln!(
"Validation failed: {} error(s), {} warning(s).",
error_count, warning_count
);
}
return 1;
}
if !is_envelope && !parsed.quiet {
if has_warnings {
println!(
"Validation passed with {} warning(s) ({} rules across {} rule set(s)).",
warning_count, rule_count, rule_set_count
);
} else {
println!(
"Validation passed ({} rules across {} rule set(s)).",
rule_count, rule_set_count
);
}
}
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_args_requires_config() {
let args: Vec<String> = vec!["--quiet".to_owned()];
assert!(ValidateArgs::parse(&args).is_err());
}
#[test]
fn parse_args_minimal() {
let args: Vec<String> = vec!["--config".to_owned(), "apimock.toml".to_owned()];
let a = ValidateArgs::parse(&args).unwrap();
assert_eq!(a.config_path, "apimock.toml");
assert!(!a.strict);
assert!(!a.quiet);
assert!(!a.json);
}
#[test]
fn parse_args_all_flags() {
let args: Vec<String> = vec![
"-c".to_owned(),
"config.toml".to_owned(),
"--strict".to_owned(),
"--quiet".to_owned(),
"--json".to_owned(),
];
let a = ValidateArgs::parse(&args).unwrap();
assert_eq!(a.config_path, "config.toml");
assert!(a.strict);
assert!(a.quiet);
assert!(a.json);
}
#[test]
fn run_missing_config_file_returns_2() {
let args: Vec<String> = vec![
"--config".to_owned(),
"/nonexistent/apimock.toml".to_owned(),
"--quiet".to_owned(),
];
assert_eq!(run(&args), 2);
}
}