1use std::collections::HashSet;
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Category, Changelog, Format};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10#[non_exhaustive]
11pub enum Severity {
12 Warning,
14 Error,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21#[non_exhaustive]
22pub enum DiagnosticCode {
23 MissingTitle,
25 MissingDate,
27 InvalidDate,
29 DatedPendingRelease,
31 DuplicateRelease,
33 PendingReleaseOrder,
35 ReleaseDateOrder,
37 CustomCategory,
39 UncategorizedContent,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[non_exhaustive]
46pub struct Diagnostic {
47 pub severity: Severity,
49 pub code: DiagnosticCode,
51 pub line: Option<usize>,
53 pub message: String,
55}
56
57impl Diagnostic {
58 #[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#[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 §ion.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#[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}