use std::collections::HashSet;
use std::fmt;
use serde::Serialize;
use crate::changes::{ArtifactLayout, artifact_layout, note_has_authored_content};
use crate::grammar::{Requirement, parse_delta_specification};
use crate::rendering::RenderedDocument;
use crate::schemata::{ArtifactGrammar, WorkflowSchema};
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic {
pub note: String,
pub artifact_id: String,
pub line: Option<usize>,
pub message: String,
}
impl fmt::Display for Diagnostic {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.line {
Some(line) => write!(
formatter,
"{note}:{line}: [{artifact}] {message}",
note = self.note,
artifact = self.artifact_id,
message = self.message,
),
None => write!(
formatter,
"{note}: [{artifact}] {message}",
note = self.note,
artifact = self.artifact_id,
message = self.message,
),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ValidationFailure {
pub change_id: String,
pub diagnostics: Vec<Diagnostic>,
}
impl fmt::Display for ValidationFailure {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let count = self.diagnostics.len();
let noun = if count == 1 {
"violation"
} else {
"violations"
};
write!(
formatter,
"change {change_id} is invalid: {count} {noun}",
change_id = self.change_id,
)?;
for diagnostic in &self.diagnostics {
write!(formatter, "\n{diagnostic}")?;
}
Ok(())
}
}
impl std::error::Error for ValidationFailure {}
pub fn validate_change(
documents: &[RenderedDocument],
schema: &WorkflowSchema,
change_folder: &str,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
for artifact in &schema.artifacts {
if !artifact.required {
continue;
}
let layout = artifact_layout(artifact);
let authored = documents.iter().any(|document| {
document.artifact_id == artifact.id
&& match layout {
ArtifactLayout::Note(_) => note_has_authored_content(&document.content),
ArtifactLayout::Folder(_) => true,
}
});
if authored {
continue;
}
let note = match layout {
ArtifactLayout::Note(stem) => format!("{change_folder}/{stem}.md"),
ArtifactLayout::Folder(folder) => format!("{change_folder}/{folder}/"),
};
diagnostics.push(Diagnostic {
note,
artifact_id: artifact.id.clone(),
line: None,
message: "required artifact has no authored content".to_string(),
});
}
for document in documents {
let Some(artifact) = schema.artifact(&document.artifact_id) else {
continue;
};
if artifact.grammar != Some(ArtifactGrammar::DeltaSpecification) {
continue;
}
let authored = match artifact_layout(artifact) {
ArtifactLayout::Note(_) => note_has_authored_content(&document.content),
ArtifactLayout::Folder(_) => true,
};
if !authored {
continue;
}
validate_delta_document(document, &mut diagnostics);
}
diagnostics
}
fn validate_delta_document(document: &RenderedDocument, diagnostics: &mut Vec<Diagnostic>) {
let delta = parse_delta_specification(&document.content);
let presence = delta.presence;
if !(presence.added || presence.modified || presence.removed || presence.renamed) {
diagnostics.push(document_diagnostic(
document,
None,
"no delta sections (ADDED, MODIFIED, REMOVED, or RENAMED Requirements)".to_string(),
));
return;
}
if presence.added && delta.added.is_empty() {
diagnostics.push(document_diagnostic(
document,
None,
"ADDED Requirements section declares no requirements".to_string(),
));
}
if presence.modified && delta.modified.is_empty() {
diagnostics.push(document_diagnostic(
document,
None,
"MODIFIED Requirements section declares no requirements".to_string(),
));
}
if presence.removed && delta.removed.is_empty() {
diagnostics.push(document_diagnostic(
document,
None,
"REMOVED Requirements section names no requirements".to_string(),
));
}
if presence.renamed && delta.renamed.is_empty() {
diagnostics.push(document_diagnostic(
document,
None,
"RENAMED Requirements section contains no FROM:/TO: pairs".to_string(),
));
}
validate_requirements(&delta.added, "ADDED", document, diagnostics);
validate_requirements(&delta.modified, "MODIFIED", document, diagnostics);
}
fn validate_requirements(
requirements: &[Requirement],
section: &str,
document: &RenderedDocument,
diagnostics: &mut Vec<Diagnostic>,
) {
let mut seen: HashSet<&str> = HashSet::new();
for requirement in requirements {
if !seen.insert(requirement.name.as_str()) {
diagnostics.push(document_diagnostic(
document,
Some(requirement.line),
format!(
"duplicate requirement name in {section} Requirements: {}",
requirement.name
),
));
}
if requirement.text.is_none() {
diagnostics.push(document_diagnostic(
document,
Some(requirement.line),
format!("requirement {} has no normative text", requirement.name),
));
}
if requirement.scenarios.is_empty() {
diagnostics.push(document_diagnostic(
document,
Some(requirement.line),
format!(
"requirement {} has no #### Scenario: block",
requirement.name
),
));
}
for scenario in &requirement.scenarios {
if scenario.body.is_empty() {
diagnostics.push(document_diagnostic(
document,
Some(scenario.line),
format!("scenario {} has no WHEN/THEN content", scenario.name),
));
}
}
}
}
fn document_diagnostic(
document: &RenderedDocument,
line: Option<usize>,
message: String,
) -> Diagnostic {
Diagnostic {
note: document.source_note.clone(),
artifact_id: document.artifact_id.clone(),
line,
message,
}
}