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::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
}
fn add_render_diagnostics(&mut self, diagnostics: Vec<Diagnostic>) {
for diag in diagnostics {
if diag.severity == Severity::Error || !self.issues.contains(&diag) {
self.issues.push(diag);
}
}
}
}
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) {
let checked = match quill.backend_id() {
"typst" => quillmark::typst_plate_file(quill).map(|_| ()),
"acroform" => quillmark::acroform_check_form(quill),
_ => Ok(()),
};
if let Err(e) = checked {
result.issues.extend(e.into_diagnostics());
}
}
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");
}
result.add_render_diagnostics(warnings);
}
Err(failure) => {
result.add(
Severity::Error,
format!("the {label} document {}", failure.what),
"cli::canonical_document_failed",
);
result.add_render_diagnostics(failure.diagnostics);
}
}
}
let Some(example) = example else { return };
let Some(document) = &example.document else { return };
let (failed, diagnostics) = match render(document) {
Ok(warnings) => (None, warnings),
Err(failure) => (Some(failure.what), failure.diagnostics),
};
let fresh: Vec<Diagnostic> = diagnostics
.into_iter()
.filter(|d| {
d.severity == Severity::Error
|| (!result.issues.contains(d) && !example.diagnostics.contains(d))
})
.collect();
match failed {
None if verbose && fresh.is_empty() => println!(" example: ok"),
None => {}
Some(what) => example.diagnostics.push(
Diagnostic::new(Severity::Error, format!("the example document {what}"))
.with_code("cli::example_render_failed".to_string()),
),
}
example.diagnostics.extend(fresh);
}
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
);
}
}