mod frontmatter;
#[cfg(test)]
mod tests;
use callisto_model::{Severity, SeverityParseError};
use frontmatter::{needs_quoting, parse_entry_line, LineError};
use schemars::JsonSchema;
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct Changeset {
pub entries: Vec<Entry>,
pub summary: String,
}
#[derive(Clone, Debug, PartialEq, Eq, JsonSchema)]
pub struct Entry {
pub name: String,
pub severity: Severity,
}
impl Changeset {
pub fn to_markdown(&self) -> Result<String, WriteError> {
write_changeset(self)
}
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParseError {
#[error("changeset does not start with a `---` frontmatter delimiter on line 1")]
MissingFrontmatterStart,
#[error("frontmatter opened with `---` on line 1 but was never closed with a matching `---`")]
UnclosedFrontmatter,
#[error("line {line}: quoted name is never closed with a matching `\"`")]
UnclosedQuotedName { line: usize },
#[error("line {line}: quoted name `{raw}` is followed by unexpected content before the `:` separator")]
AmbiguousNameQuoting { line: usize, raw: String },
#[error("line {line}: no `:` separator found in {raw:?}")]
MissingSeparator { line: usize, raw: String },
#[error("line {line}: package name is empty")]
EmptyName { line: usize },
#[error("line {line}: invalid severity for package {name:?}: {source}")]
InvalidSeverity {
line: usize,
name: String,
#[source]
source: SeverityParseError,
},
#[error("line {line}: package {name:?} is named more than once in this changeset's frontmatter (first on line {first_line})")]
DuplicateEntry {
line: usize,
first_line: usize,
name: String,
},
#[error("changeset has no frontmatter entries and an empty summary")]
EmptyChangeset,
}
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum WriteError {
#[error("cannot write changeset: no entries and an empty summary")]
EmptyChangeset,
#[error("entry {index} has an empty package name")]
EmptyName { index: usize },
#[error("entry {index} name {name:?} contains a literal `\"`, which cannot be written (no escaping convention is defined for this grammar)")]
NameContainsQuote { index: usize, name: String },
}
pub fn parse_changeset(source: &str) -> Result<Changeset, ParseError> {
let trimmed_bom = source.strip_prefix('\u{FEFF}').unwrap_or(source);
let normalized = trimmed_bom.replace("\r\n", "\n");
let lines: Vec<&str> = normalized.split('\n').collect();
if lines.first().copied() != Some("---") {
return Err(ParseError::MissingFrontmatterStart);
}
let closing_index = lines[1..].iter().position(|&l| l == "---").map(|i| i + 1);
let Some(closing_index) = closing_index else {
return Err(ParseError::UnclosedFrontmatter);
};
let mut entries: Vec<Entry> = Vec::new();
let mut first_seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (offset, &line) in lines[1..closing_index].iter().enumerate() {
let line_no = offset + 2; if line.trim().is_empty() || line.trim_start().starts_with('#') {
continue;
}
let entry = parse_entry_line(line).map_err(|e| promote_line_error(e, line_no))?;
if let Some(&first_line) = first_seen.get(&entry.name) {
return Err(ParseError::DuplicateEntry {
line: line_no,
first_line,
name: entry.name,
});
}
first_seen.insert(entry.name.clone(), line_no);
entries.push(entry);
}
let summary = lines[closing_index + 1..].join("\n").trim().to_string();
if entries.is_empty() && summary.is_empty() {
return Err(ParseError::EmptyChangeset);
}
Ok(Changeset { entries, summary })
}
fn promote_line_error(err: LineError, line: usize) -> ParseError {
match err {
LineError::UnclosedQuotedName => ParseError::UnclosedQuotedName { line },
LineError::AmbiguousNameQuoting { raw } => ParseError::AmbiguousNameQuoting { line, raw },
LineError::MissingSeparator { raw } => ParseError::MissingSeparator { line, raw },
LineError::EmptyName => ParseError::EmptyName { line },
LineError::InvalidSeverity { name, source } => {
ParseError::InvalidSeverity { line, name, source }
}
}
}
pub fn write_changeset(changeset: &Changeset) -> Result<String, WriteError> {
if changeset.entries.is_empty() && changeset.summary.trim().is_empty() {
return Err(WriteError::EmptyChangeset);
}
for (index, entry) in changeset.entries.iter().enumerate() {
if entry.name.is_empty() {
return Err(WriteError::EmptyName { index });
}
if entry.name.contains('"') {
return Err(WriteError::NameContainsQuote {
index,
name: entry.name.clone(),
});
}
}
let mut out = String::from("---\n");
for entry in &changeset.entries {
if needs_quoting(&entry.name) {
out.push_str(&format!("\"{}\": {}\n", entry.name, entry.severity));
} else {
out.push_str(&format!("{}: {}\n", entry.name, entry.severity));
}
}
out.push_str("---\n\n");
out.push_str(changeset.summary.trim());
out.push('\n');
Ok(out)
}