use crate::bundle::Bundle;
use crate::concept_id::ConceptId;
use crate::document::Document;
use crate::log::{is_iso_date, Log};
use std::fs;
use std::path::PathBuf;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
Info,
}
impl std::fmt::Display for Severity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
})
}
}
#[derive(Clone, Debug)]
pub struct Diagnostic {
pub severity: Severity,
pub path: Option<PathBuf>,
pub concept: Option<ConceptId>,
pub message: String,
}
impl std::fmt::Display for Diagnostic {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] ", self.severity)?;
if let Some(p) = &self.path {
write!(f, "{}: ", p.display())?;
} else if let Some(c) = &self.concept {
write!(f, "{c}: ")?;
}
f.write_str(&self.message)
}
}
#[derive(Clone, Debug, Default)]
pub struct Report {
pub diagnostics: Vec<Diagnostic>,
}
impl Report {
pub fn is_conformant(&self) -> bool {
!self.diagnostics.iter().any(|d| d.severity == Severity::Error)
}
pub fn of(&self, severity: Severity) -> impl Iterator<Item = &Diagnostic> {
self.diagnostics.iter().filter(move |d| d.severity == severity)
}
pub fn error_count(&self) -> usize {
self.of(Severity::Error).count()
}
pub fn warning_count(&self) -> usize {
self.of(Severity::Warning).count()
}
}
pub fn validate_bundle(bundle: &Bundle) -> Report {
let mut report = Report::default();
for (path, error) in bundle.parse_errors() {
report.diagnostics.push(Diagnostic {
severity: Severity::Error,
path: Some(path.clone()),
concept: None,
message: format!("unparseable concept document: {error}"),
});
}
for concept in bundle.concepts() {
let fm = &concept.document.frontmatter;
if concept.document.validate_conformance().is_err() {
report.diagnostics.push(Diagnostic {
severity: Severity::Error,
path: Some(concept.path.clone()),
concept: Some(concept.id.clone()),
message: "missing required frontmatter field `type`".to_string(),
});
}
for field in ["title", "description", "timestamp"] {
if fm.get(field).map(|v| v.is_empty_value()).unwrap_or(true) {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(concept.path.clone()),
concept: Some(concept.id.clone()),
message: format!("missing recommended frontmatter field `{field}`"),
});
}
}
if let Some(ts) = fm.timestamp() {
if !is_iso8601_datetime(&ts) {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(concept.path.clone()),
concept: Some(concept.id.clone()),
message: format!("`timestamp` is not ISO-8601: {ts:?}"),
});
}
}
}
validate_reserved(bundle, &mut report);
for (source, raw) in bundle.broken_links() {
report.diagnostics.push(Diagnostic {
severity: Severity::Info,
path: None,
concept: Some(source),
message: format!("link target does not resolve to a concept in the bundle: {raw}"),
});
}
report
}
fn validate_reserved(bundle: &Bundle, report: &mut Report) {
let root_index = bundle.root().join("index.md");
for path in bundle.index_files() {
let Ok(text) = fs::read_to_string(path) else { continue };
let Ok(doc) = Document::parse(&text) else { continue };
if doc.frontmatter.is_empty() {
continue;
}
let is_root = path == &root_index;
if !is_root {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(path.clone()),
concept: None,
message: "index.md should not contain frontmatter (§6)".to_string(),
});
} else {
let only_version = doc.frontmatter.as_mapping().keys().all(|k| k == "okf_version");
if !only_version {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(path.clone()),
concept: None,
message: "root index.md frontmatter should declare only `okf_version` (§11)"
.to_string(),
});
}
}
}
for path in bundle.log_files() {
let Ok(text) = fs::read_to_string(path) else { continue };
let log = Log::parse(&text);
for bad in log.invalid_dates() {
report.diagnostics.push(Diagnostic {
severity: Severity::Warning,
path: Some(path.clone()),
concept: None,
message: format!("log date heading is not ISO-8601 `YYYY-MM-DD`: {bad:?}"),
});
}
}
}
pub fn is_iso8601_datetime(s: &str) -> bool {
let date_part = s.split(['T', ' ']).next().unwrap_or(s);
is_iso_date(date_part)
}