use std::path::{Path, PathBuf};
use chrono::{DateTime, FixedOffset};
use dbmd_core::validate::{validate_all, validate_working_set};
use dbmd_core::{Config, Issue, Severity, Store};
use crate::cli::ValidateArgs;
use crate::context::Context;
use crate::error::{CliError, CliResult, ExitCode};
pub fn run(ctx: &Context, args: &ValidateArgs) -> CliResult {
let root = Path::new(&args.dir);
let store = if Store::is_db_md_store(root) {
Store::open(root).map_err(|e| CliError::from(dbmd_core::Error::from(e)))?
} else {
Store {
root: root.to_path_buf(),
config: Config::default(),
}
};
let scope = if args.all { "all" } else { "working-set" };
let issues = if args.all {
validate_all(&store).map_err(CliError::from)?
} else {
let since = parse_since(args.since.as_deref())?;
validate_working_set(&store, since).map_err(CliError::from)?
};
let counts = Counts::of(&issues);
if ctx.json {
print!("{}", json_report(scope, &args.dir, &counts, &issues));
} else {
print!("{}", text_report(&counts, &issues));
}
if counts.errors > 0 {
return Err(CliError::new(
ExitCode::ValidationFailed,
"VALIDATION_FAILED",
format!(
"validation found {} error{}",
counts.errors,
if counts.errors == 1 { "" } else { "s" }
),
));
}
Ok(())
}
fn parse_since(raw: Option<&str>) -> Result<Option<DateTime<FixedOffset>>, CliError> {
let Some(raw) = raw else { return Ok(None) };
let raw = raw.trim();
if let Ok(ts) = DateTime::parse_from_rfc3339(raw) {
return Ok(Some(ts));
}
if let Ok(ts) = DateTime::parse_from_rfc3339(&format!("{raw}T00:00:00Z")) {
return Ok(Some(ts));
}
Err(CliError::new(
ExitCode::Runtime,
"BAD_TIMESTAMP",
format!("`--since` value `{raw}` is not RFC3339 or a YYYY-MM-DD date"),
)
.with_hint("use e.g. 2026-05-27 or 2026-05-27T08:00:00-07:00"))
}
struct Counts {
errors: usize,
warnings: usize,
info: usize,
}
impl Counts {
fn of(issues: &[Issue]) -> Self {
let mut c = Counts {
errors: 0,
warnings: 0,
info: 0,
};
for issue in issues {
match issue.severity {
Severity::Error => c.errors += 1,
Severity::Warning => c.warnings += 1,
Severity::Info => c.info += 1,
}
}
c
}
fn total(&self) -> usize {
self.errors + self.warnings + self.info
}
}
fn text_report(counts: &Counts, issues: &[Issue]) -> String {
let mut out = String::new();
for issue in issues {
out.push_str(&format!(
"{} {} {}",
severity_word(issue.severity),
issue.code,
issue.file.display()
));
if let Some(line) = issue.line {
out.push_str(&format!(":{line}"));
}
if let Some(key) = &issue.key {
out.push_str(&format!(" [{key}]"));
}
out.push_str(&format!(" — {}", issue.message));
out.push('\n');
if let Some(suggestion) = &issue.suggestion {
out.push_str(&format!(" hint: {suggestion}\n"));
}
}
out.push_str(&format!(
"{} issue(s): {} error(s), {} warning(s), {} info\n",
counts.total(),
counts.errors,
counts.warnings,
counts.info
));
out
}
fn json_report(scope: &str, store: &str, counts: &Counts, issues: &[Issue]) -> String {
let mut sorted: Vec<&Issue> = issues.iter().collect();
sorted.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.line.cmp(&b.line))
.then(a.code.cmp(b.code))
});
let issues_json: Vec<serde_json::Value> = sorted.iter().map(|i| issue_json(i)).collect();
let obj = serde_json::json!({
"scope": scope,
"store": store,
"summary": {
"errors": counts.errors,
"warnings": counts.warnings,
"info": counts.info,
"total": counts.total(),
},
"issues": issues_json,
});
let mut s = serde_json::to_string_pretty(&obj).unwrap_or_else(|_| "{}".to_string());
s.push('\n');
s
}
fn issue_json(issue: &Issue) -> serde_json::Value {
let related: Vec<String> = issue
.related
.iter()
.map(|p: &PathBuf| p.to_string_lossy().into_owned())
.collect();
serde_json::json!({
"severity": severity_word(issue.severity),
"code": issue.code,
"file": issue.file.to_string_lossy(),
"line": issue.line,
"key": issue.key,
"message": issue.message,
"suggestion": issue.suggestion,
"related": related,
})
}
fn severity_word(severity: Severity) -> &'static str {
match severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
}
}