callisto_format/changeset/
mod.rs1mod frontmatter;
2#[cfg(test)]
3mod tests;
4
5use callisto_model::{Severity, SeverityParseError};
6use frontmatter::{needs_quoting, parse_entry_line, LineError};
7
8use schemars::JsonSchema;
15
16#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
23pub struct Changeset {
24 pub entries: Vec<Entry>,
25 pub summary: String,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
34pub struct Entry {
35 pub name: String,
36 pub severity: Severity,
37}
38
39impl Changeset {
40 pub fn to_markdown(&self) -> Result<String, WriteError> {
42 write_changeset(self)
43 }
44}
45
46#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum ParseError {
49 #[error("changeset does not start with a `---` frontmatter delimiter on line 1")]
51 #[diagnostic(
52 code(E040),
53 help("Add a `---` frontmatter delimiter on line 1 of the changeset file.")
54 )]
55 MissingFrontmatterStart,
56
57 #[error("frontmatter opened with `---` on line 1 but was never closed with a matching `---`")]
59 #[diagnostic(code(E041), help("Ensure frontmatter block closes with `---`."))]
60 UnclosedFrontmatter,
61
62 #[error("line {line}: quoted name is never closed with a matching `\"`")]
64 #[diagnostic(code(E042))]
65 UnclosedQuotedName { line: usize },
66
67 #[error("line {line}: quoted name `{raw}` is followed by unexpected content before the `:` separator")]
69 #[diagnostic(code(E043))]
70 AmbiguousNameQuoting { line: usize, raw: String },
71
72 #[error("line {line}: no `:` separator found in {raw:?}")]
74 #[diagnostic(code(E044))]
75 MissingSeparator { line: usize, raw: String },
76
77 #[error("line {line}: package name is empty")]
79 #[diagnostic(code(E045))]
80 EmptyName { line: usize },
81
82 #[error("line {line}: invalid severity for package {name:?}: {source}")]
84 #[diagnostic(code(E046))]
85 InvalidSeverity {
86 line: usize,
87 name: String,
88 #[source]
89 source: SeverityParseError,
90 },
91
92 #[error("line {line}: package {name:?} is named more than once in this changeset's frontmatter (first on line {first_line})")]
95 #[diagnostic(code(E047))]
96 DuplicateEntry {
97 line: usize,
98 first_line: usize,
99 name: String,
100 },
101
102 #[error("changeset has no frontmatter entries and an empty summary")]
104 #[diagnostic(code(E048))]
105 EmptyChangeset,
106
107 #[error("changeset has entries but an empty or whitespace-only summary")]
109 #[diagnostic(
110 code(E048),
111 help("Add a non-empty summary after the closing `---` delimiter.")
112 )]
113 EmptySummary,
114}
115
116#[derive(Debug, thiserror::Error, miette::Diagnostic, Clone, PartialEq, Eq)]
117#[non_exhaustive]
118pub enum WriteError {
119 #[error("cannot write changeset: no entries and an empty summary")]
121 #[diagnostic(code(E049))]
122 EmptyChangeset,
123
124 #[error("cannot write changeset: entries present but summary is empty or whitespace-only")]
126 #[diagnostic(code(E049), help("Provide a non-empty summary describing the change."))]
127 EmptySummary,
128
129 #[error("entry {index} has an empty package name")]
131 #[diagnostic(code(E049))]
132 EmptyName { index: usize },
133
134 #[error("entry {index} name {name:?} contains a literal `\"`, which cannot be written (no escaping convention is defined for this grammar)")]
137 NameContainsQuote { index: usize, name: String },
138}
139
140pub fn parse_changeset(source: &str) -> Result<Changeset, ParseError> {
147 let trimmed_bom = source.strip_prefix('\u{FEFF}').unwrap_or(source);
148 let normalized = trimmed_bom.replace("\r\n", "\n").replace('\r', "\n");
149 let lines: Vec<&str> = normalized.split('\n').collect();
150
151 if lines.first().map(|l| l.trim_end()) != Some("---") {
152 return Err(ParseError::MissingFrontmatterStart);
153 }
154
155 let closing_index = lines[1..]
162 .iter()
163 .position(|&l| l.trim_end() == "---")
164 .map(|i| i + 1);
165 let Some(closing_index) = closing_index else {
166 return Err(ParseError::UnclosedFrontmatter);
167 };
168
169 let mut entries: Vec<Entry> = Vec::new();
170 let mut first_seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
171
172 for (offset, &line) in lines[1..closing_index].iter().enumerate() {
173 let line_no = offset + 2; if line.trim().is_empty() || line.trim_start().starts_with('#') {
175 continue;
176 }
177 let entry = parse_entry_line(line).map_err(|e| promote_line_error(e, line_no))?;
178 if let Some(&first_line) = first_seen.get(&entry.name) {
179 return Err(ParseError::DuplicateEntry {
180 line: line_no,
181 first_line,
182 name: entry.name,
183 });
184 }
185 first_seen.insert(entry.name.clone(), line_no);
186 entries.push(entry);
187 }
188
189 let summary = lines[closing_index + 1..].join("\n").trim().to_string();
190
191 if entries.is_empty() && summary.is_empty() {
192 return Err(ParseError::EmptyChangeset);
193 }
194 if !entries.is_empty() && summary.is_empty() {
195 return Err(ParseError::EmptySummary);
196 }
197
198 Ok(Changeset { entries, summary })
199}
200
201fn promote_line_error(err: LineError, line: usize) -> ParseError {
202 match err {
203 LineError::UnclosedQuotedName => ParseError::UnclosedQuotedName { line },
204 LineError::AmbiguousNameQuoting { raw } => ParseError::AmbiguousNameQuoting { line, raw },
205 LineError::MissingSeparator { raw } => ParseError::MissingSeparator { line, raw },
206 LineError::EmptyName => ParseError::EmptyName { line },
207 LineError::InvalidSeverity { name, source } => {
208 ParseError::InvalidSeverity { line, name, source }
209 }
210 }
211}
212
213pub fn write_changeset(changeset: &Changeset) -> Result<String, WriteError> {
218 if changeset.summary.trim().is_empty() {
219 if changeset.entries.is_empty() {
220 return Err(WriteError::EmptyChangeset);
221 }
222 return Err(WriteError::EmptySummary);
223 }
224 for (index, entry) in changeset.entries.iter().enumerate() {
225 if entry.name.is_empty() {
226 return Err(WriteError::EmptyName { index });
227 }
228 if entry.name.contains('"') {
229 return Err(WriteError::NameContainsQuote {
230 index,
231 name: entry.name.clone(),
232 });
233 }
234 }
235
236 let mut out = String::from("---\n");
237 for entry in &changeset.entries {
238 if needs_quoting(&entry.name) {
239 out.push_str(&format!("\"{}\": {}\n", entry.name, entry.severity));
240 } else {
241 out.push_str(&format!("{}: {}\n", entry.name, entry.severity));
242 }
243 }
244 out.push_str("---\n\n");
245 out.push_str(changeset.summary.trim());
246 out.push('\n');
247 Ok(out)
248}