Skip to main content

logloom/
validation.rs

1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Category, Changelog, Format};
6
7/// Importance of a convention diagnostic. Parsing itself has already succeeded.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Severity {
12    /// Accepted but unconventional content.
13    Warning,
14    /// Content with invalid semantics, such as an impossible calendar date.
15    Error,
16}
17
18/// Stable machine-readable convention diagnostic codes.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21#[non_exhaustive]
22pub enum DiagnosticCode {
23    /// No document title is present.
24    MissingTitle,
25    /// A published release has no date.
26    MissingDate,
27    /// A date is not a valid Gregorian YYYY-MM-DD value.
28    InvalidDate,
29    /// Pending changes have a date attached.
30    DatedPendingRelease,
31    /// A release identifier occurs more than once.
32    DuplicateRelease,
33    /// Pending changes appear after published releases.
34    PendingReleaseOrder,
35    /// Published release dates are not in descending order.
36    ReleaseDateOrder,
37    /// A group uses an unknown category name.
38    CustomCategory,
39    /// A release contains content outside a named category.
40    UncategorizedContent,
41}
42
43/// A convention check result with a one-based source line when available.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct Diagnostic {
47    /// Severity of the finding.
48    pub severity: Severity,
49    /// Machine-readable classification.
50    pub code: DiagnosticCode,
51    /// Original source line, when available.
52    pub line: Option<usize>,
53    /// Human-readable explanation.
54    pub message: String,
55}
56
57impl Diagnostic {
58    /// Construct a convention finding without a source location.
59    #[must_use]
60    pub const fn new(severity: Severity, code: DiagnosticCode, message: String) -> Self {
61        Self {
62            severity,
63            code,
64            line: None,
65            message,
66        }
67    }
68}
69
70/// Check the source format's conventions without mutating or sorting content.
71///
72/// Empty releases and categories are accepted. Version identifiers are opaque.
73/// Dates use four-digit years 0001–9999 and the Gregorian calendar. Unknown
74/// categories, missing dates, and unusual ordering produce Keep a Changelog warnings; invalid
75/// dates and duplicate identifiers produce errors. This checks the unified
76/// model, rather than attempting to validate every Markdown convention.
77#[must_use]
78pub fn validate(changelog: &Changelog) -> Vec<Diagnostic> {
79    validate_for_format(changelog, changelog.format)
80}
81
82fn validate_keep_a_changelog(changelog: &Changelog) -> Vec<Diagnostic> {
83    let mut diagnostics = Vec::new();
84    let mut report = |severity, code, line, message: &str| {
85        diagnostics.push(Diagnostic {
86            severity,
87            code,
88            line,
89            message: message.to_owned(),
90        });
91    };
92    if changelog.metadata.title.is_none() {
93        report(
94            Severity::Warning,
95            DiagnosticCode::MissingTitle,
96            None,
97            "document has no title",
98        );
99    }
100    let mut versions = HashSet::new();
101    let mut previous_date: Option<&str> = None;
102    let mut published_seen = false;
103    for release in &changelog.releases {
104        let line = Some(release.span.line);
105        if !versions.insert(release.version.as_deref()) {
106            report(
107                Severity::Error,
108                DiagnosticCode::DuplicateRelease,
109                line,
110                "duplicate release identifier",
111            );
112        }
113        if release.version.is_none() && published_seen {
114            report(
115                Severity::Warning,
116                DiagnosticCode::PendingReleaseOrder,
117                line,
118                "pending changes usually precede published releases",
119            );
120        }
121        published_seen |= release.version.is_some();
122        match &release.date {
123            Some(date) => {
124                if !valid_date(date) {
125                    report(
126                        Severity::Error,
127                        DiagnosticCode::InvalidDate,
128                        line,
129                        "expected a real calendar date in YYYY-MM-DD format",
130                    );
131                } else if release.version.is_some() {
132                    if previous_date.is_some_and(|previous| previous < date.as_str()) {
133                        report(
134                            Severity::Warning,
135                            DiagnosticCode::ReleaseDateOrder,
136                            line,
137                            "release dates usually appear newest first",
138                        );
139                    }
140                    previous_date = Some(date);
141                }
142                if release.version.is_none() {
143                    report(
144                        Severity::Warning,
145                        DiagnosticCode::DatedPendingRelease,
146                        line,
147                        "pending changes usually have no release date",
148                    );
149                }
150            }
151            None if release.version.is_some() => report(
152                Severity::Warning,
153                DiagnosticCode::MissingDate,
154                line,
155                "published release has no date",
156            ),
157            None => {}
158        }
159        for section in &release.sections {
160            match &section.category {
161                Some(Category::Custom(_)) => report(
162                    Severity::Warning,
163                    DiagnosticCode::CustomCategory,
164                    Some(section.span.line),
165                    "category is outside the standard vocabulary",
166                ),
167                None if !section.entries.is_empty() => report(
168                    Severity::Warning,
169                    DiagnosticCode::UncategorizedContent,
170                    Some(section.span.line),
171                    "content appears outside a named category",
172                ),
173                _ => {}
174            }
175        }
176    }
177    diagnostics
178}
179
180/// Validate with the conventions of the selected input format.
181///
182/// [`validate`] uses the stored source format; this function explicitly overrides it.
183/// Keep a Changelog retains its original checks. Other formats check
184/// real ISO calendar dates and duplicate version identifiers within each package,
185/// without warning about missing dates, titles, or format-specific categories.
186/// Unversioned notes and date/author records do not invent duplicate versions.
187#[must_use]
188pub fn validate_for_format(changelog: &Changelog, format: Format) -> Vec<Diagnostic> {
189    if format == Format::KeepAChangelog {
190        return validate_keep_a_changelog(changelog);
191    }
192    let mut diagnostics = Vec::new();
193    let mut versions = HashSet::new();
194    for release in &changelog.releases {
195        if let Some(version) = &release.version
196            && !versions.insert((release.package(), version))
197        {
198            diagnostics.push(Diagnostic {
199                severity: Severity::Error,
200                code: DiagnosticCode::DuplicateRelease,
201                line: Some(release.span.line),
202                message: "duplicate version identifier within the same package".to_owned(),
203            });
204        }
205        if release
206            .date
207            .as_deref()
208            .is_some_and(|date| !valid_date(date))
209        {
210            diagnostics.push(Diagnostic {
211                severity: Severity::Error,
212                code: DiagnosticCode::InvalidDate,
213                line: Some(release.date_span.unwrap_or(release.span).line),
214                message: "expected a real calendar date in YYYY-MM-DD format".to_owned(),
215            });
216        }
217    }
218    diagnostics
219}
220
221pub fn valid_date(date: &str) -> bool {
222    if date.len() != 10
223        || !date
224            .bytes()
225            .all(|byte| byte.is_ascii_digit() || byte == b'-')
226    {
227        return false;
228    }
229    let mut parts = date.split('-');
230    let (Some(year), Some(month), Some(day), None) =
231        (parts.next(), parts.next(), parts.next(), parts.next())
232    else {
233        return false;
234    };
235    if year.len() != 4 || month.len() != 2 || day.len() != 2 {
236        return false;
237    }
238    let (Ok(year), Ok(month), Ok(day)) =
239        (year.parse::<u16>(), month.parse::<u8>(), day.parse::<u8>())
240    else {
241        return false;
242    };
243    let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
244    let max_day = match month {
245        4 | 6 | 9 | 11 => 30,
246        2 if leap => 29,
247        2 => 28,
248        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
249        _ => return false,
250    };
251    year != 0 && day != 0 && day <= max_day
252}