use crate::sdd::project::config::load_required_config;
use crate::sdd::shared::constants::LLMANSPEC_DIR_NAME;
use crate::sdd::shared::discovery::{list_changes, list_specs};
use crate::sdd::shared::ids::validate_sdd_id;
use crate::sdd::shared::interactive::is_interactive;
use crate::sdd::shared::match_utils::nearest_matches;
use crate::sdd::spec::staleness::{StalenessEvaluator, StalenessInfo, evaluate_staleness};
use crate::sdd::spec::validation::{
ValidationIssue, ValidationLevel, ValidationReport, ValidationSummary,
validate_change_delta_specs, validate_spec_content_with_frontmatter,
};
use anyhow::{Result, anyhow};
use inquire::Select;
use serde::Serialize;
use std::fmt;
use std::fs;
use std::path::Path;
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct ValidateArgs {
pub item: Option<String>,
pub all: bool,
pub changes: bool,
pub specs: bool,
pub item_type: Option<String>,
pub strict: bool,
pub json: bool,
pub compact_json: bool,
pub no_interactive: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ItemType {
Change,
Spec,
}
impl ItemType {
fn as_str(self) -> &'static str {
match self {
ItemType::Change => "change",
ItemType::Spec => "spec",
}
}
}
impl fmt::Display for ItemType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self {
ItemType::Change => t!("sdd.validate.option_change"),
ItemType::Spec => t!("sdd.validate.option_spec"),
};
write!(f, "{label}")
}
}
#[derive(Debug, Serialize)]
struct ValidationItem {
id: String,
#[serde(rename = "type")]
item_type: String,
valid: bool,
issues: Vec<ValidationIssue>,
#[serde(rename = "durationMs")]
duration_ms: u128,
staleness: StalenessInfo,
}
pub fn run(args: ValidateArgs) -> Result<()> {
let root = Path::new(".");
let llmanspec_dir = root.join(LLMANSPEC_DIR_NAME);
let _config = load_required_config(&llmanspec_dir)?;
let interactive = is_interactive(args.no_interactive);
let type_override = normalize_type(args.item_type.as_deref());
if args.all || args.changes || args.specs {
let do_changes = args.all || args.changes;
let do_specs = args.all || args.specs;
run_bulk_validation(
root,
do_changes,
do_specs,
args.strict,
args.json,
args.compact_json,
)?;
return Ok(());
}
if args.item.is_none() {
if interactive {
run_interactive_selector(root, args.strict, args.json, args.compact_json)?;
return Ok(());
}
return Err(anyhow!(non_interactive_hint_message()));
}
let Some(item) = args.item.as_deref() else {
return Err(anyhow!(non_interactive_hint_message()));
};
validate_direct(
root,
item,
type_override,
args.strict,
args.json,
args.compact_json,
)
}
fn normalize_type(value: Option<&str>) -> Option<ItemType> {
let value = value?.to_lowercase();
match value.as_str() {
"change" => Some(ItemType::Change),
"spec" => Some(ItemType::Spec),
_ => None,
}
}
fn run_interactive_selector(
root: &Path,
strict: bool,
json: bool,
compact_json: bool,
) -> Result<()> {
let choice = Select::new(
&t!("sdd.validate.select_scope"),
vec![
t!("sdd.validate.option_all"),
t!("sdd.validate.option_changes"),
t!("sdd.validate.option_specs"),
t!("sdd.validate.option_pick_one"),
],
)
.prompt()?;
if choice == t!("sdd.validate.option_all") {
run_bulk_validation(root, true, true, strict, json, compact_json)?;
return Ok(());
}
if choice == t!("sdd.validate.option_changes") {
run_bulk_validation(root, true, false, strict, json, compact_json)?;
return Ok(());
}
if choice == t!("sdd.validate.option_specs") {
run_bulk_validation(root, false, true, strict, json, compact_json)?;
return Ok(());
}
let changes = list_changes(root)?;
let specs = list_specs(root)?;
let mut items = Vec::new();
items.extend(changes.iter().map(|id| format!("change/{id}")));
items.extend(specs.iter().map(|id| format!("spec/{id}")));
if items.is_empty() {
return Err(anyhow!(t!("sdd.validate.no_items")));
}
let picked = Select::new(&t!("sdd.validate.pick_item"), items).prompt()?;
let (item_type, id) = parse_prefixed_item(&picked)?;
validate_by_type(root, item_type, &id, strict, json, compact_json)
}
fn parse_prefixed_item(value: &str) -> Result<(ItemType, String)> {
if let Some((prefix, id)) = value.split_once('/') {
let item_type = match prefix {
"change" => ItemType::Change,
"spec" => ItemType::Spec,
_ => return Err(anyhow!(t!("sdd.validate.invalid_pick"))),
};
return Ok((item_type, id.to_string()));
}
Err(anyhow!(t!("sdd.validate.invalid_pick")))
}
fn validate_direct(
root: &Path,
item: &str,
type_override: Option<ItemType>,
strict: bool,
json: bool,
compact_json: bool,
) -> Result<()> {
let changes = list_changes(root)?;
let specs = list_specs(root)?;
let is_change = changes.contains(&item.to_string());
let is_spec = specs.contains(&item.to_string());
if let Some(ItemType::Change) = type_override
&& !is_change
{
let suggestions = nearest_matches(item, &changes, 5);
return Err(anyhow!(unknown_item_message(item, &suggestions)));
}
if let Some(ItemType::Spec) = type_override
&& !is_spec
{
let suggestions = nearest_matches(item, &specs, 5);
return Err(anyhow!(unknown_item_message(item, &suggestions)));
}
let resolved_type = type_override.or(if is_change {
Some(ItemType::Change)
} else if is_spec {
Some(ItemType::Spec)
} else {
None
});
let Some(resolved_type) = resolved_type else {
let suggestions = nearest_matches(item, &[changes, specs].concat(), 5);
return Err(anyhow!(unknown_item_message(item, &suggestions)));
};
if type_override.is_none() && is_change && is_spec {
return Err(anyhow!(
"{}\n{}",
t!("sdd.validate.ambiguous_item", item = item),
t!("sdd.validate.ambiguous_hint")
));
}
validate_by_type(root, resolved_type, item, strict, json, compact_json)
}
fn validate_by_type(
root: &Path,
item_type: ItemType,
id: &str,
strict: bool,
json: bool,
compact_json: bool,
) -> Result<()> {
let start = Instant::now();
let (report, staleness) = match item_type {
ItemType::Change => {
validate_sdd_id(id, "change")?;
let change_dir = root.join(LLMANSPEC_DIR_NAME).join("changes").join(id);
let report = validate_change_delta_specs(&change_dir, strict);
(report, StalenessInfo::not_applicable())
}
ItemType::Spec => {
validate_sdd_id(id, "spec")?;
let spec_path = root
.join(LLMANSPEC_DIR_NAME)
.join("specs")
.join(id)
.join("spec.md");
match fs::read_to_string(&spec_path) {
Ok(content) => {
let validation =
validate_spec_content_with_frontmatter(&spec_path, &content, strict);
let staleness =
evaluate_staleness(root, id, &spec_path, validation.frontmatter.as_ref());
let mut issues = validation.report.issues.clone();
issues.extend(apply_strict(staleness.issues, strict));
let valid = validation.report.valid
&& !issues
.iter()
.any(|issue| issue.level == ValidationLevel::Error);
let report = ValidationReport {
valid,
issues,
summary: validation.report.summary,
};
(report, staleness.info)
}
Err(err) => {
let report =
error_report(t!("sdd.validate.spec_read_failed", error = err).to_string());
(report, StalenessInfo::not_applicable())
}
}
}
};
let duration_ms = start.elapsed().as_millis();
if json {
let items = vec![ValidationItem {
id: id.to_string(),
item_type: item_type.as_str().to_string(),
valid: report.valid,
issues: report.issues.clone(),
duration_ms,
staleness: staleness.clone(),
}];
let summary = summary_for_items(&items, &[item_type]);
let output = serde_json::json!({
"items": items,
"summary": summary,
"version": "1.0"
});
print_json(&output, compact_json)?;
} else {
print_single_report(item_type, id, &report, &staleness);
}
if !report.valid {
return Err(anyhow!("validation failed"));
}
Ok(())
}
fn print_single_report(
item_type: ItemType,
id: &str,
report: &ValidationReport,
staleness: &StalenessInfo,
) {
if report.valid {
println!(
"{}",
t!(
"sdd.validate.item_valid",
item = item_label(item_type),
id = id
)
);
print_staleness(item_type, staleness);
return;
}
eprintln!(
"{}",
t!(
"sdd.validate.item_invalid",
item = item_label(item_type),
id = id
)
);
for issue in &report.issues {
let label = match issue.level {
ValidationLevel::Error => "ERROR",
ValidationLevel::Warning => "WARNING",
ValidationLevel::Info => "INFO",
};
eprintln!(
"{}",
t!(
"sdd.validate.issue_line",
label = label,
path = issue.path,
message = issue.message
)
);
}
print_staleness(item_type, staleness);
print_next_steps(item_type);
}
fn print_next_steps(item_type: ItemType) {
eprintln!("{}", t!("sdd.validate.next_steps"));
match item_type {
ItemType::Change => {
eprintln!("{}", t!("sdd.validate.change_step_1"));
eprintln!("{}", t!("sdd.validate.change_step_2"));
eprintln!("{}", t!("sdd.validate.change_step_3"));
}
ItemType::Spec => {
eprintln!("{}", t!("sdd.validate.spec_step_1"));
eprintln!("{}", t!("sdd.validate.spec_step_2"));
eprintln!("{}", t!("sdd.validate.spec_step_3"));
}
}
}
fn item_label(item_type: ItemType) -> &'static str {
match item_type {
ItemType::Change => "Change",
ItemType::Spec => "Specification",
}
}
fn print_staleness(item_type: ItemType, staleness: &StalenessInfo) {
if item_type != ItemType::Spec {
return;
}
println!(
"{}",
t!(
"sdd.validate.staleness_status",
status = staleness.status.as_str()
)
);
if !staleness.touched_paths.is_empty() {
println!(
"{}",
t!(
"sdd.validate.staleness_touched",
paths = staleness.touched_paths.join(", ")
)
);
}
if staleness.spec_updated {
println!("{}", t!("sdd.validate.staleness_spec_updated"));
}
if staleness.dirty {
println!("{}", t!("sdd.validate.staleness_dirty"));
}
for note in &staleness.notes {
println!("{}", t!("sdd.validate.staleness_note", note = note));
}
}
fn run_bulk_validation(
root: &Path,
validate_changes: bool,
validate_specs: bool,
strict: bool,
json: bool,
compact_json: bool,
) -> Result<()> {
let changes = if validate_changes {
list_changes(root)?
} else {
Vec::new()
};
let specs = if validate_specs {
list_specs(root)?
} else {
Vec::new()
};
let mut items: Vec<ValidationItem> = Vec::new();
for id in changes {
let start = Instant::now();
validate_sdd_id(&id, "change")?;
let report = validate_change_delta_specs(
&root.join(LLMANSPEC_DIR_NAME).join("changes").join(&id),
strict,
);
items.push(ValidationItem {
id,
item_type: "change".to_string(),
valid: report.valid,
issues: report.issues,
duration_ms: start.elapsed().as_millis(),
staleness: StalenessInfo::not_applicable(),
});
}
let staleness_evaluator = StalenessEvaluator::new(root);
for id in specs {
let start = Instant::now();
validate_sdd_id(&id, "spec")?;
let spec_path = root
.join(LLMANSPEC_DIR_NAME)
.join("specs")
.join(&id)
.join("spec.md");
match fs::read_to_string(&spec_path) {
Ok(content) => {
let validation =
validate_spec_content_with_frontmatter(&spec_path, &content, strict);
let staleness = staleness_evaluator.evaluate(
&id,
&spec_path,
validation.frontmatter.as_ref(),
None,
);
let mut issues = validation.report.issues;
issues.extend(apply_strict(staleness.issues, strict));
let valid = validation.report.valid
&& !issues
.iter()
.any(|issue| issue.level == ValidationLevel::Error);
let report = ValidationReport {
valid,
issues,
summary: validation.report.summary,
};
items.push(ValidationItem {
id,
item_type: "spec".to_string(),
valid: report.valid,
issues: report.issues,
duration_ms: start.elapsed().as_millis(),
staleness: staleness.info,
});
}
Err(err) => {
let report =
error_report(t!("sdd.validate.spec_read_failed", error = err).to_string());
items.push(ValidationItem {
id,
item_type: "spec".to_string(),
valid: report.valid,
issues: report.issues,
duration_ms: start.elapsed().as_millis(),
staleness: StalenessInfo::not_applicable(),
});
}
}
}
if items.is_empty() {
if json {
let summary = empty_summary(validate_changes, validate_specs);
let output = serde_json::json!({
"items": [],
"summary": summary,
"version": "1.0"
});
print_json(&output, compact_json)?;
} else {
println!("{}", t!("sdd.validate.no_items"));
}
return Ok(());
}
items.sort_by(|a, b| a.id.cmp(&b.id).then_with(|| a.item_type.cmp(&b.item_type)));
let mut allowed = Vec::new();
if validate_changes {
allowed.push(ItemType::Change);
}
if validate_specs {
allowed.push(ItemType::Spec);
}
let summary = summary_for_items(&items, &allowed);
if json {
let output = serde_json::json!({
"items": items,
"summary": summary,
"version": "1.0"
});
print_json(&output, compact_json)?;
} else {
let passed = items.iter().filter(|item| item.valid).count();
let failed = items.len() - passed;
for item in &items {
if item.valid {
println!(
"{}",
t!(
"sdd.validate.bulk_ok",
item = format!("{}/{}", item.item_type, item.id)
)
);
} else {
eprintln!(
"{}",
t!(
"sdd.validate.bulk_fail",
item = format!("{}/{}", item.item_type, item.id)
)
);
}
if item.item_type == "spec" {
print_staleness(ItemType::Spec, &item.staleness);
}
}
println!(
"{}",
t!(
"sdd.validate.bulk_summary",
passed = passed,
failed = failed,
items = items.len()
)
);
}
let failed = items.iter().filter(|item| !item.valid).count();
if failed > 0 {
return Err(anyhow!("validation failed"));
}
Ok(())
}
fn unknown_item_message(item: &str, suggestions: &[String]) -> String {
let mut msg = t!("sdd.validate.unknown_item", item = item).to_string();
if !suggestions.is_empty() {
msg.push('\n');
msg.push_str(&t!(
"sdd.validate.did_you_mean",
items = suggestions.join(", ")
));
}
msg
}
fn apply_strict(mut issues: Vec<ValidationIssue>, strict: bool) -> Vec<ValidationIssue> {
if !strict {
return issues;
}
for issue in &mut issues {
if issue.level == ValidationLevel::Warning {
issue.level = ValidationLevel::Error;
}
}
issues
}
fn error_report(message: String) -> ValidationReport {
ValidationReport {
valid: false,
issues: vec![ValidationIssue {
level: ValidationLevel::Error,
path: "file".to_string(),
message,
}],
summary: ValidationSummary {
errors: 1,
warnings: 0,
info: 0,
},
}
}
fn summary_for_items(items: &[ValidationItem], allowed: &[ItemType]) -> serde_json::Value {
let mut totals = SummaryCounts::default();
let mut by_type = std::collections::BTreeMap::new();
for item in items {
totals.items += 1;
if item.valid {
totals.passed += 1;
} else {
totals.failed += 1;
}
let entry = by_type
.entry(item.item_type.clone())
.or_insert_with(SummaryCounts::default);
entry.items += 1;
if item.valid {
entry.passed += 1;
} else {
entry.failed += 1;
}
}
for allowed_type in allowed {
let key = allowed_type.as_str().to_string();
by_type.entry(key).or_insert_with(SummaryCounts::default);
}
serde_json::json!({
"totals": totals,
"byType": by_type
})
}
fn empty_summary(include_changes: bool, include_specs: bool) -> serde_json::Value {
let mut by_type = serde_json::Map::new();
if include_changes {
by_type.insert(
"change".to_string(),
serde_json::json!({"items": 0, "passed": 0, "failed": 0}),
);
}
if include_specs {
by_type.insert(
"spec".to_string(),
serde_json::json!({"items": 0, "passed": 0, "failed": 0}),
);
}
serde_json::json!({
"totals": { "items": 0, "passed": 0, "failed": 0 },
"byType": by_type
})
}
fn print_json(value: &serde_json::Value, compact: bool) -> Result<()> {
if compact {
println!("{}", serde_json::to_string(value)?);
} else {
println!("{}", serde_json::to_string_pretty(value)?);
}
Ok(())
}
#[derive(Default, Serialize)]
struct SummaryCounts {
items: usize,
passed: usize,
failed: usize,
}
fn non_interactive_hint_message() -> String {
[
t!("sdd.validate.non_interactive.line1"),
t!("sdd.validate.non_interactive.line2"),
t!("sdd.validate.non_interactive.line3"),
t!("sdd.validate.non_interactive.line4"),
t!("sdd.validate.non_interactive.line5"),
t!("sdd.validate.non_interactive.line6"),
]
.join("\n")
}