use crate::errors::{CliError, Result};
use clap::Parser;
use quillmark::{
CardSchema, Diagnostic, Document, FieldSchema, Quill, Quillmark, RenderOptions, Severity,
EXAMPLE_FILE,
};
use indexmap::IndexMap;
use std::path::{Path, PathBuf};
#[derive(Parser)]
pub struct ValidateArgs {
#[arg(value_name = "QUILL_PATH")]
quill_path: PathBuf,
#[arg(short, long)]
verbose: bool,
#[arg(long)]
no_render: bool,
}
#[derive(Debug, Default)]
struct ValidationResult {
issues: Vec<Diagnostic>,
}
impl ValidationResult {
fn add(&mut self, severity: Severity, message: impl Into<String>, code: &str) {
self.issues
.push(Diagnostic::new(severity, message.into()).with_code(code.to_string()));
}
fn count(&self, severity: Severity) -> usize {
self.issues.iter().filter(|d| d.severity == severity).count()
}
fn has_errors(&self) -> bool {
self.count(Severity::Error) > 0
}
}
pub fn execute(args: ValidateArgs) -> Result<()> {
let quill_yaml_path = args.quill_path.join("Quill.yaml");
if args.quill_path.is_dir() && !quill_yaml_path.exists() {
return Err(CliError::InvalidArgument(format!(
"Quill.yaml not found in: {}",
args.quill_path.display()
)));
}
if args.verbose {
println!("Validating quill at: {}", args.quill_path.display());
}
let mut result = ValidationResult::default();
let quill = quillmark::quill_from_path(&args.quill_path)?;
let config = quill.config();
result.issues.extend(quill.warnings().iter().cloned());
if args.verbose {
println!(" Quill name: {}", config.name);
println!(" Backend: {}", config.backend);
println!(" Fields: {}", config.main.fields.len());
println!(" Cards: {}", config.card_kinds.len());
println!(" Schema generated successfully");
println!(" Defaults extracted: {}", config.main.defaults().len());
}
validate_file_references(&quill, &mut result);
validate_field_schemas(&config.main.fields, &mut result, "field");
for card_schema in &config.card_kinds {
validate_card_schema(&card_schema.name, card_schema, &mut result);
}
let render = !args.no_render && !result.has_errors();
let mut example = read_example(&quill);
if args.verbose {
match example {
Some(_) => println!(" Example: {EXAMPLE_FILE}"),
None => println!(" Example: none (no {EXAMPLE_FILE} at the quill root)"),
}
}
if render {
validate_renders(&quill, example.as_mut(), &mut result, args.verbose);
}
if let Some(example) = example {
report_example(example, &mut result);
}
print_validation_result(&result, args.verbose);
if result.has_errors() {
Err(CliError::Reported)
} else {
Ok(())
}
}
fn validate_file_references(quill: &Quill, result: &mut ValidationResult) {
if let Some(plate_file) = quill
.config()
.backend_config
.get("plate_file")
.and_then(|v| v.as_str())
{
let rel = Path::new(plate_file);
if rel
.components()
.any(|c| !matches!(c, std::path::Component::Normal(_)))
{
result.add(
Severity::Error,
format!(
"plate_file '{}' must be a relative path within the quill (no '..' or absolute components)",
plate_file
),
"cli::plate_file_escapes_quill",
);
} else if quill.files().get_file(rel).is_none() {
result.add(
Severity::Error,
format!("Referenced plate_file '{}' does not exist", plate_file),
"cli::plate_file_missing",
);
}
}
}
struct Example {
document: Option<Document>,
diagnostics: Vec<Diagnostic>,
}
fn read_example(quill: &Quill) -> Option<Example> {
Some(match quill.example_document()? {
Ok(parsed) => {
let mut diagnostics = parsed.warnings;
diagnostics.extend(quill.validate(&parsed.document));
let fails = diagnostics.iter().any(|d| d.severity == Severity::Error);
Example {
document: (!fails).then_some(parsed.document),
diagnostics,
}
}
Err(diagnostics) => Example {
document: None,
diagnostics,
},
})
}
fn report_example(example: Example, result: &mut ValidationResult) {
if example.diagnostics.is_empty() {
return;
}
result.add(
Severity::Error,
format!(
"the example document '{EXAMPLE_FILE}' carries {} diagnostic(s)",
example.diagnostics.len()
),
"cli::example_not_clean",
);
result
.issues
.extend(example.diagnostics.into_iter().map(|mut d| {
d.severity = Severity::Error;
d
}));
}
struct RenderFailure {
what: String,
diagnostics: Vec<Diagnostic>,
}
fn validate_renders(
quill: &Quill,
example: Option<&mut Example>,
result: &mut ValidationResult,
verbose: bool,
) {
let engine = Quillmark::new();
let format = match engine.supported_formats(quill) {
Ok([format, ..]) => *format,
Ok([]) => {
result.add(
Severity::Error,
"the quill's backend declares no output format",
"cli::backend_unresolved",
);
return;
}
Err(e) => {
result.add(
Severity::Error,
"the quill's backend does not resolve",
"cli::backend_unresolved",
);
result.issues.extend(e.into_diagnostics());
return;
}
};
if verbose {
println!(" Rendering the canonical documents to {}", format);
}
let blueprint = quill.config().blueprint();
let documents = [
("empty", Ok(quill.empty_document())),
(
"blueprint",
Document::parse(&blueprint).map(|parsed| parsed.document),
),
("seeded", Ok(quill.seed_document())),
];
let options = RenderOptions::default().with_output_format(format);
let today = super::render_date(None);
let render = |document: &Document| -> std::result::Result<Vec<Diagnostic>, RenderFailure> {
match engine.render(quill, document, today, &options) {
Ok(rendered) if rendered.artifacts.iter().all(|a| a.bytes.is_empty()) => {
Err(RenderFailure {
what: format!("rendered no {format} bytes"),
diagnostics: Vec::new(),
})
}
Ok(rendered) => Ok(rendered.warnings),
Err(e) => Err(RenderFailure {
what: "does not render through the quill's plate".to_string(),
diagnostics: e.into_diagnostics(),
}),
}
};
for (label, document) in documents {
let document = match document {
Ok(document) => document,
Err(e) => {
result.add(
Severity::Error,
format!("the {label} document does not parse: {}", e),
"cli::canonical_document_failed",
);
continue;
}
};
match render(&document) {
Ok(warnings) => {
if verbose {
println!(" {label}: ok");
}
for warning in warnings {
if !result.issues.contains(&warning) {
result.issues.push(warning);
}
}
}
Err(failure) => {
result.add(
Severity::Error,
format!("the {label} document {}", failure.what),
"cli::canonical_document_failed",
);
result.issues.extend(failure.diagnostics);
}
}
}
let Some(example) = example else { return };
let Some(document) = &example.document else { return };
match render(document) {
Ok(warnings) => {
let fresh: Vec<Diagnostic> = warnings
.into_iter()
.filter(|w| !result.issues.contains(w) && !example.diagnostics.contains(w))
.collect();
if verbose && fresh.is_empty() {
println!(" example: ok");
}
example.diagnostics.extend(fresh);
}
Err(failure) => {
example.diagnostics.push(
Diagnostic::new(
Severity::Error,
format!("the example document {}", failure.what),
)
.with_code("cli::example_render_failed".to_string()),
);
example.diagnostics.extend(failure.diagnostics);
}
}
}
fn validate_field_schemas(
fields: &IndexMap<String, FieldSchema>,
result: &mut ValidationResult,
context: &str,
) {
for (field_name, field_schema) in fields {
if field_schema
.description
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
result.add(
Severity::Warning,
format!("{context} '{field_name}': missing or empty description"),
"cli::missing_description",
);
}
}
}
fn validate_card_schema(card_name: &str, card_schema: &CardSchema, result: &mut ValidationResult) {
if card_schema
.description
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
result.add(
Severity::Warning,
format!("card '{}': missing or empty description", card_name),
"cli::missing_description",
);
}
let context = format!("card '{}' field", card_name);
validate_field_schemas(&card_schema.fields, result, &context);
}
fn print_validation_result(result: &ValidationResult, verbose: bool) {
let error_count = result.count(Severity::Error);
let warning_count = result.count(Severity::Warning);
for diag in &result.issues {
if diag.severity == Severity::Error || verbose {
eprintln!("{}", diag.fmt_pretty());
}
}
if error_count == 0 && warning_count == 0 {
println!("Validation passed: quill configuration is valid");
} else if error_count == 0 {
println!("Validation passed with {} warning(s)", warning_count);
} else {
eprintln!(
"Validation failed: {} error(s), {} warning(s)",
error_count, warning_count
);
}
}