use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::entry::Entry;
use crate::identifier::EntryAddress;
use crate::structural::StructuralSettings;
const CATEGORY_FIELD: &str = "category";
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CheckMode {
Edit,
Review,
}
impl CheckMode {
pub fn severity(self) -> CheckSeverity {
match self {
| Self::Edit => CheckSeverity::Warning,
| Self::Review => CheckSeverity::Error,
}
}
pub fn check_entries<'a>(
self, entries: impl IntoIterator<Item = &'a Entry>, structural: &StructuralSettings,
) -> CheckReport {
self.check_entries_with_structural_inhabitance(entries, structural, true)
}
pub fn check_entries_with_structural_inhabitance<'a>(
self, entries: impl IntoIterator<Item = &'a Entry>, structural: &StructuralSettings,
structural_inhabitance: bool,
) -> CheckReport {
let entries = entries.into_iter().collect::<Vec<_>>();
let entries_by_id =
entries.iter().map(|entry| (entry.id.clone(), *entry)).collect::<BTreeMap<_, _>>();
let severity = self.severity();
let mut report = CheckReport::new();
if structural_inhabitance {
for (field, _) in structural.fields() {
if !entries_by_id.keys().any(|id| id.as_str() == field) {
report.push(CheckDiagnostic {
severity,
kind: CheckDiagnosticKind::MissingStructuralFieldEntry,
entry: None,
field: field.to_owned(),
target: None,
});
}
}
}
for entry in &entries {
for (field, targets) in entry.metadata.structural_fields() {
if !structural.contains_field(field) {
report.push(CheckDiagnostic {
severity: CheckSeverity::Warning,
kind: CheckDiagnosticKind::UnconfiguredStructuralField,
entry: Some(entry.id.clone()),
field: field.to_owned(),
target: None,
});
continue;
}
for target in targets {
if !entries_by_id.contains_key(target) {
report.push(CheckDiagnostic {
severity,
kind: CheckDiagnosticKind::MissingTarget,
entry: Some(entry.id.clone()),
field: field.to_owned(),
target: Some(target.clone()),
});
}
}
}
}
self.check_category_targets(&entries_by_id, structural, &mut report);
report
}
fn check_category_targets(
self, entries_by_id: &BTreeMap<EntryAddress, &Entry>, structural: &StructuralSettings,
report: &mut CheckReport,
) {
let category_id =
EntryAddress::new(CATEGORY_FIELD).expect("built-in category entry address is valid");
let category_targets = entries_by_id
.values()
.flat_map(|entry| entry.metadata.structural_targets_for(CATEGORY_FIELD))
.cloned()
.collect::<BTreeSet<_>>();
if category_targets.is_empty() && !structural.contains_field(CATEGORY_FIELD) {
return;
}
if !entries_by_id.contains_key(&category_id) {
report.push(CheckDiagnostic {
severity: CheckSeverity::Warning,
kind: CheckDiagnosticKind::MissingCategoryEntry,
entry: None,
field: CATEGORY_FIELD.to_owned(),
target: Some(category_id.clone()),
});
}
for target in category_targets {
let Some(target_entry) = entries_by_id.get(&target) else {
continue;
};
let has_category_marker = target_entry
.metadata
.structural_targets_for(CATEGORY_FIELD)
.iter()
.any(|id| id == &category_id);
if !has_category_marker {
report.push(CheckDiagnostic {
severity: self.severity(),
kind: CheckDiagnosticKind::CategoryTargetMissingCategoryMarker,
entry: Some(target.clone()),
field: CATEGORY_FIELD.to_owned(),
target: Some(category_id.clone()),
});
}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CheckSeverity {
Warning,
Error,
}
impl CheckSeverity {
pub fn label(self) -> &'static str {
match self {
| Self::Warning => "warning",
| Self::Error => "error",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CheckDiagnosticKind {
MissingStructuralFieldEntry,
UnconfiguredStructuralField,
MissingTarget,
MissingCategoryEntry,
CategoryTargetMissingCategoryMarker,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CheckDiagnostic {
pub severity: CheckSeverity,
pub kind: CheckDiagnosticKind,
pub entry: Option<EntryAddress>,
pub field: String,
pub target: Option<EntryAddress>,
}
impl CheckDiagnostic {
pub fn message(&self) -> String {
match self.kind {
| CheckDiagnosticKind::MissingStructuralFieldEntry => format!(
"`Sirno.toml` configures structural field `{}`, but entry `{}` does not exist",
self.field, self.field
),
| CheckDiagnosticKind::UnconfiguredStructuralField => format!(
"`{}` uses structural field `{}` that is not configured in `Sirno.toml`",
self.entry.as_ref().expect("unconfigured field diagnostic has entry"),
self.field
),
| CheckDiagnosticKind::MissingTarget => format!(
"`{}` references missing entry `{}` through `{}`",
self.entry.as_ref().expect("missing target diagnostic has entry"),
self.target.as_ref().expect("missing target diagnostic has target"),
self.field
),
| CheckDiagnosticKind::MissingCategoryEntry => {
"`category` metadata needs entry `category`; add it with `sirno util entry`"
.to_owned()
}
| CheckDiagnosticKind::CategoryTargetMissingCategoryMarker => format!(
"`{}` is used as a category target, but it is not categorized by `{}`",
self.entry.as_ref().expect("category target diagnostic has entry"),
self.target.as_ref().expect("category target diagnostic has target")
),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct CheckReport {
diagnostics: Vec<CheckDiagnostic>,
}
impl CheckReport {
pub fn new() -> Self {
Self::default()
}
pub fn push(&mut self, diagnostic: CheckDiagnostic) {
self.diagnostics.push(diagnostic);
}
pub fn diagnostics(&self) -> &[CheckDiagnostic] {
&self.diagnostics
}
pub fn is_clean(&self) -> bool {
self.diagnostics.is_empty()
}
pub fn has_errors(&self) -> bool {
self.diagnostics.iter().any(|diagnostic| diagnostic.severity == CheckSeverity::Error)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entry::EntryMetadata;
use crate::structural::StructuralFieldSettings;
const FIELD_TOPIC: &str = "topic";
const FIELD_CATEGORY: &str = "category";
fn entry(id: &str) -> Entry {
Entry::new(EntryAddress::new(id).unwrap(), EntryMetadata::new(id, "desc").unwrap(), "")
}
fn structural_settings() -> StructuralSettings {
StructuralSettings::from_fields([(FIELD_TOPIC, StructuralFieldSettings::default())])
}
fn category_settings() -> StructuralSettings {
StructuralSettings::from_fields([(FIELD_CATEGORY, StructuralFieldSettings::default())])
}
#[test]
fn clean_entries_produce_clean_report() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_TOPIC, EntryAddress::new("meta").unwrap());
let mut meta = entry("meta");
meta.metadata.push_structural_target(FIELD_TOPIC, EntryAddress::new("meta").unwrap());
let topic = entry(FIELD_TOPIC);
let report =
CheckMode::Review.check_entries([&concept, &meta, &topic], &structural_settings());
assert!(report.is_clean());
}
#[test]
fn edit_mode_reports_dangling_reference_as_warning() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_TOPIC, EntryAddress::new("meta").unwrap());
let topic = entry(FIELD_TOPIC);
let report = CheckMode::Edit.check_entries([&concept, &topic], &structural_settings());
assert_eq!(report.diagnostics()[0].kind, CheckDiagnosticKind::MissingTarget);
assert_eq!(report.diagnostics()[0].severity, CheckSeverity::Warning);
assert!(!report.has_errors());
}
#[test]
fn review_mode_reports_dangling_reference_as_error() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_TOPIC, EntryAddress::new("meta").unwrap());
let topic = entry(FIELD_TOPIC);
let report = CheckMode::Review.check_entries([&concept, &topic], &structural_settings());
assert_eq!(report.diagnostics()[0].kind, CheckDiagnosticKind::MissingTarget);
assert_eq!(report.diagnostics()[0].severity, CheckSeverity::Error);
assert!(report.has_errors());
}
#[test]
fn edit_mode_reports_missing_structural_field_entry_as_warning() {
let concept = entry("concept");
let report = CheckMode::Edit.check_entries([&concept], &structural_settings());
assert_eq!(report.diagnostics()[0].kind, CheckDiagnosticKind::MissingStructuralFieldEntry);
assert_eq!(report.diagnostics()[0].severity, CheckSeverity::Warning);
assert!(!report.has_errors());
}
#[test]
fn review_mode_reports_missing_structural_field_entry_as_error() {
let concept = entry("concept");
let report = CheckMode::Review.check_entries([&concept], &structural_settings());
assert_eq!(report.diagnostics()[0].kind, CheckDiagnosticKind::MissingStructuralFieldEntry);
assert_eq!(report.diagnostics()[0].severity, CheckSeverity::Error);
assert!(report.has_errors());
assert!(report.diagnostics()[0].message().contains("entry `topic` does not exist"));
}
#[test]
fn structural_inhabitance_can_be_skipped() {
let concept = entry("concept");
let report = CheckMode::Review.check_entries_with_structural_inhabitance(
[&concept],
&structural_settings(),
false,
);
assert!(report.is_clean());
}
#[test]
fn unconfigured_structural_fields_warn() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_TOPIC, EntryAddress::new("meta").unwrap());
let report = CheckMode::Review.check_entries([&concept], &StructuralSettings::default());
assert_eq!(report.diagnostics()[0].kind, CheckDiagnosticKind::UnconfiguredStructuralField);
assert_eq!(report.diagnostics()[0].severity, CheckSeverity::Warning);
assert!(!report.has_errors());
}
#[test]
fn category_metadata_warns_when_category_entry_is_missing() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_CATEGORY, EntryAddress::new("meta").unwrap());
let mut meta = entry("meta");
meta.metadata
.push_structural_target(FIELD_CATEGORY, EntryAddress::new("category").unwrap());
let report = CheckMode::Review.check_entries([&concept, &meta], &category_settings());
assert!(
report
.diagnostics()
.iter()
.any(|diagnostic| diagnostic.kind == CheckDiagnosticKind::MissingCategoryEntry
&& diagnostic.severity == CheckSeverity::Warning)
);
}
#[test]
fn review_mode_reports_category_target_without_category_marker_as_error() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_CATEGORY, EntryAddress::new("meta").unwrap());
let meta = entry("meta");
let mut category = entry("category");
category
.metadata
.push_structural_target(FIELD_CATEGORY, EntryAddress::new("category").unwrap());
let report =
CheckMode::Review.check_entries([&concept, &meta, &category], &category_settings());
let diagnostic = report
.diagnostics()
.iter()
.find(|diagnostic| {
diagnostic.kind == CheckDiagnosticKind::CategoryTargetMissingCategoryMarker
})
.expect("category target marker diagnostic");
assert_eq!(diagnostic.entry.as_ref().unwrap().as_str(), "meta");
assert_eq!(diagnostic.severity, CheckSeverity::Error);
assert!(report.has_errors());
}
#[test]
fn edit_mode_reports_category_target_without_category_marker_as_warning() {
let mut concept = entry("concept");
concept.metadata.push_structural_target(FIELD_CATEGORY, EntryAddress::new("meta").unwrap());
let meta = entry("meta");
let mut category = entry("category");
category
.metadata
.push_structural_target(FIELD_CATEGORY, EntryAddress::new("category").unwrap());
let report =
CheckMode::Edit.check_entries([&concept, &meta, &category], &category_settings());
let diagnostic = report
.diagnostics()
.iter()
.find(|diagnostic| {
diagnostic.kind == CheckDiagnosticKind::CategoryTargetMissingCategoryMarker
})
.expect("category target marker diagnostic");
assert_eq!(diagnostic.severity, CheckSeverity::Warning);
assert!(!report.has_errors());
}
}