#![allow(dead_code, reason = "PHASE-01 leaf — consumers arrive in later phases")]
use serde::Serialize;
use std::fmt;
use std::fmt::Write;
const CATEGORY_NAME_ID_INTEGRITY: &str = "Id Integrity";
const CATEGORY_NAME_RELATION_INTEGRITY: &str = "Relation Integrity";
const CATEGORY_NAME_SPEC_FK: &str = "Spec Foreign Key";
const CATEGORY_NAME_MEMORY_HEALTH: &str = "Memory Health";
const CATEGORY_NAME_LIFECYCLE: &str = "Lifecycle";
const CATEGORY_NAME_RAW_LABEL: &str = "Raw Label";
const CATEGORY_NAME_TOML_PARSE: &str = "TOML Parse";
const CATEGORY_NAME_PROSE_CITE: &str = "Prose Citation";
const SEVERITY_ERROR: &str = "error";
const SEVERITY_WARNING: &str = "warning";
const CORPUS_CLEAN: &str = "doctor: corpus clean";
const FINDING_COUNT_FMT: &str = "{} finding(s)";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Severity {
Error,
Warning,
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Error => SEVERITY_ERROR,
Self::Warning => SEVERITY_WARNING,
};
f.write_str(s)
}
}
impl Serialize for Severity {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let s = match self {
Self::Error => SEVERITY_ERROR,
Self::Warning => SEVERITY_WARNING,
};
serializer.serialize_str(s)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Category {
IdIntegrity,
RelationIntegrity,
SpecFk,
MemoryHealth,
Lifecycle,
RawLabel,
TomlParse,
ProseCite,
}
impl Category {
#[must_use]
pub(crate) const fn severity(self) -> Severity {
match self {
Self::IdIntegrity | Self::RelationIntegrity | Self::SpecFk | Self::MemoryHealth => {
Severity::Error
}
Self::Lifecycle | Self::RawLabel | Self::TomlParse | Self::ProseCite => {
Severity::Warning
}
}
}
#[must_use]
const fn ordinal(self) -> u8 {
match self {
Self::IdIntegrity => 0,
Self::RelationIntegrity => 1,
Self::SpecFk => 2,
Self::MemoryHealth => 3,
Self::Lifecycle => 4,
Self::RawLabel => 5,
Self::TomlParse => 6,
Self::ProseCite => 7,
}
}
#[must_use]
const fn display_name(self) -> &'static str {
match self {
Self::IdIntegrity => CATEGORY_NAME_ID_INTEGRITY,
Self::RelationIntegrity => CATEGORY_NAME_RELATION_INTEGRITY,
Self::SpecFk => CATEGORY_NAME_SPEC_FK,
Self::MemoryHealth => CATEGORY_NAME_MEMORY_HEALTH,
Self::Lifecycle => CATEGORY_NAME_LIFECYCLE,
Self::RawLabel => CATEGORY_NAME_RAW_LABEL,
Self::TomlParse => CATEGORY_NAME_TOML_PARSE,
Self::ProseCite => CATEGORY_NAME_PROSE_CITE,
}
}
}
impl fmt::Display for Category {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.display_name())
}
}
impl Serialize for Category {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(self.display_name())
}
}
const CATEGORIES_BY_ORDINAL: [Category; 8] = [
Category::IdIntegrity,
Category::RelationIntegrity,
Category::SpecFk,
Category::MemoryHealth,
Category::Lifecycle,
Category::RawLabel,
Category::TomlParse,
Category::ProseCite,
];
#[derive(Debug, Clone)]
pub(crate) struct Finding {
pub category: Category,
pub entity: Option<String>,
pub message: String,
}
impl Serialize for Finding {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut row = serializer.serialize_struct("Finding", 4)?;
row.serialize_field("category", &self.category)?;
row.serialize_field("severity", &self.category.severity())?;
row.serialize_field("entity", &self.entity)?;
row.serialize_field("message", &self.message)?;
row.end()
}
}
impl Finding {
pub(crate) fn from_lines(category: Category, lines: Vec<String>) -> Vec<Finding> {
lines
.into_iter()
.map(|line| Finding {
category,
entity: None,
message: line,
})
.collect()
}
}
pub(crate) fn render_findings(findings: &[Finding]) -> String {
let mut by_category: [Vec<&Finding>; 8] = [
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
Vec::new(),
];
for f in findings {
let idx = usize::from(f.category.ordinal());
if let Some(bucket) = by_category.get_mut(idx) {
bucket.push(f);
}
}
let mut out = String::new();
let mut total: usize = 0;
for cat in &CATEGORIES_BY_ORDINAL {
let idx = usize::from(cat.ordinal());
let Some(group) = by_category.get(idx) else {
continue;
};
if group.is_empty() {
continue;
}
let _header = writeln!(out, "[{}]", cat.display_name());
for f in group {
let _line = writeln!(out, " {}: {}", f.category.severity(), f.message);
total = total.saturating_add(1);
}
}
if total == 0 {
out.push_str(CORPUS_CLEAN);
} else {
let _summary = write!(out, "{total} finding(s)");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_mapping() {
assert_eq!(Category::IdIntegrity.severity(), Severity::Error);
assert_eq!(Category::RelationIntegrity.severity(), Severity::Error);
assert_eq!(Category::SpecFk.severity(), Severity::Error);
assert_eq!(Category::MemoryHealth.severity(), Severity::Error);
assert_eq!(Category::Lifecycle.severity(), Severity::Warning);
assert_eq!(Category::RawLabel.severity(), Severity::Warning);
assert_eq!(Category::TomlParse.severity(), Severity::Warning);
assert_eq!(Category::ProseCite.severity(), Severity::Warning);
}
#[test]
fn test_from_lines() {
let findings = Finding::from_lines(Category::SpecFk, vec!["a".into(), "b".into()]);
assert_eq!(findings.len(), 2);
assert!(findings[0].entity.is_none());
assert!(findings[1].entity.is_none());
assert_eq!(findings[0].message, "a");
assert_eq!(findings[1].message, "b");
}
#[test]
fn test_render_empty() {
let out = render_findings(&[]);
assert!(out.contains(CORPUS_CLEAN));
assert!(!out.contains('['));
}
#[test]
fn test_render_grouped() {
let f1 = Finding {
category: Category::IdIntegrity,
entity: None,
message: "bad id".into(),
};
let f2 = Finding {
category: Category::Lifecycle,
entity: None,
message: "stale draft".into(),
};
let out = render_findings(&[f1, f2]);
assert!(out.contains(CATEGORY_NAME_ID_INTEGRITY));
assert!(out.contains(CATEGORY_NAME_LIFECYCLE));
assert!(out.contains("2 finding(s)"));
}
}