Skip to main content

callisto_format/changeset/
mod.rs

1mod frontmatter;
2#[cfg(test)]
3mod tests;
4
5use callisto_model::{Severity, SeverityParseError};
6use frontmatter::{needs_quoting, parse_entry_line, LineError};
7
8/// One parsed `.changeset/*.md` file (§6.1's shape): a frontmatter block of
9/// `name: severity` entries, followed by a free-text summary.
10///
11/// Deliberately does not carry a filename or path — this crate is filesystem-free; a caller
12/// that reads files off disk attaches the filename itself for sort ordering and error
13/// context.
14use schemars::JsonSchema;
15
16/// One parsed `.changeset/*.md` file (§6.1's shape): a frontmatter block of
17/// `name: severity` entries, followed by a free-text summary.
18///
19/// Deliberately does not carry a filename or path — this crate is filesystem-free; a caller
20/// that reads files off disk attaches the filename itself for sort ordering and error
21/// context.
22#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
23pub struct Changeset {
24    pub entries: Vec<Entry>,
25    pub summary: String,
26}
27
28/// One `"name": severity` frontmatter line, after quote resolution.
29///
30/// `name` is the raw string as written in the changeset — resolving it to a `PackageId`
31/// (bare vs. `ecosystem/name`-prefixed) is a workspace-aware operation this crate cannot
32/// perform and does not attempt.
33#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
34pub struct Entry {
35    pub name: String,
36    pub severity: Severity,
37}
38
39impl Changeset {
40    /// Convenience wrapper around [`write_changeset`].
41    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    /// The file does not open with a `---` delimiter on line 1 at all.
50    #[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    /// A `---` opened on line 1 but no matching closing `---` line was ever found.
58    #[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    /// A quoted name's opening `"` has no matching closing `"` on the same line.
63    #[error("line {line}: quoted name is never closed with a matching `\"`")]
64    #[diagnostic(code(E042))]
65    UnclosedQuotedName { line: usize },
66
67    /// The closing `"` of a quoted name is not immediately followed by the separator `:`.
68    #[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    /// A bare (unquoted) line contains no `:` at all.
73    #[error("line {line}: no `:` separator found in {raw:?}")]
74    #[diagnostic(code(E044))]
75    MissingSeparator { line: usize, raw: String },
76
77    /// The name resolved to the empty string.
78    #[error("line {line}: package name is empty")]
79    #[diagnostic(code(E045))]
80    EmptyName { line: usize },
81
82    /// The severity token is not one of `major | minor | patch | none` (case-insensitive).
83    #[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    /// The same (raw, pre-`PackageId`-resolution) name appears twice in one changeset's
93    /// frontmatter.
94    #[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    /// §6.1: "Empty frontmatter valid iff summary is non-empty."
103    #[error("changeset has no frontmatter entries and an empty summary")]
104    #[diagnostic(code(E048))]
105    EmptyChangeset,
106
107    /// A changeset with one or more entries must have a non-empty summary.
108    #[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    /// Mirrors `ParseError::EmptyChangeset`.
120    #[error("cannot write changeset: no entries and an empty summary")]
121    #[diagnostic(code(E049))]
122    EmptyChangeset,
123
124    /// A changeset with one or more entries must have a non-empty summary.
125    #[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    /// `entries[index]`'s name is the empty string.
130    #[error("entry {index} has an empty package name")]
131    #[diagnostic(code(E049))]
132    EmptyName { index: usize },
133
134    /// `entries[index]`'s name contains a literal `"`, which cannot be written — no escaping
135    /// convention is defined for this grammar.
136    #[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
140/// Parses one `.changeset/*.md` file's contents.
141///
142/// Grammar (§6.1): a `---`-delimited frontmatter block starting on line 1, each non-blank,
143/// non-comment line inside it shaped `<name>: <severity>`, followed by the file's remaining
144/// content as `summary` (trimmed). `#`-comment lines and blank lines inside the frontmatter
145/// block are skipped. CRLF line endings are normalized to LF before parsing.
146pub 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    // Two-pass, deliberately: find the frontmatter's boundaries FIRST, then parse content
156    // within them. A single pass that tries to parse every line as an entry until it
157    // happens to hit a literal "---" cannot tell "this entry is malformed" apart from "the
158    // frontmatter was never closed at all" — the first non-entry-shaped line after a missing
159    // closing delimiter would otherwise surface as a misleading parse error on that line
160    // instead of `UnclosedFrontmatter`.
161    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; // absolute, 1-indexed; line 1 was "---"
174        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
213/// Serializes a [`Changeset`] back to `.changeset/*.md` bytes.
214///
215/// Names are quoted only when necessary. Severities are always written lowercase. Output
216/// always uses `\n` line endings and ends with a single trailing newline after the summary.
217pub 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}