//! Intermediate representation (IR) for changelogs.
//!
//! The IR is a stable, reviewable snapshot of the commits that make up a
//! release range. It is serialised to TOML so it can be inspected, hand-edited,
//! committed, and later combined with other IR files before being rendered into
//! a changelog by the AI layer.
//!
//! To stay dependency-free (only `git2` is allowed), this module hand-rolls a
//! small TOML writer and a targeted TOML reader. The reader understands the
//! subset of TOML that this writer produces — `[meta]` and `[[commits]]` tables
//! with basic (quoted) string values — while tolerating comments, blank lines,
//! and surrounding whitespace so hand-edited files still parse.
use std::fmt::{self, Write as _};
use crate::git::{ChangeSet, CommitInfo};
/// A single reviewable changelog entry (one git commit).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Entry {
/// Abbreviated commit hash.
pub short_hash: String,
/// First line of the commit message.
pub summary: String,
/// Remaining commit message body (may be empty).
pub body: String,
/// Commit author name.
pub author: String,
}
impl From<&CommitInfo> for Entry {
fn from(c: &CommitInfo) -> Self {
Entry {
short_hash: c.short_hash.clone(),
summary: c.summary.clone(),
body: c.body.clone(),
author: c.author.clone(),
}
}
}
/// The intermediate representation of a changelog range.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Ir {
/// The starting revision (exclusive), as provided by the user.
pub from: String,
/// The ending revision (inclusive), as provided by the user.
pub to: String,
/// The commits in the range, newest first.
pub entries: Vec<Entry>,
}
/// Errors produced while (de)serialising or combining IR data.
#[derive(Debug, PartialEq, Eq)]
pub enum IrError {
/// A required key was missing from the `[meta]` table.
MissingMeta(&'static str),
/// A key/value line could not be parsed. Contains the 1-based line number.
MalformedLine { line: usize, message: String },
/// A key appeared outside any known table.
OrphanKey { line: usize, key: String },
/// `combine` was called with no IR inputs.
NothingToCombine,
}
impl fmt::Display for IrError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
IrError::MissingMeta(key) => {
write!(f, "IR is missing required [meta] key: {key}")
}
IrError::MalformedLine { line, message } => {
write!(f, "malformed IR at line {line}: {message}")
}
IrError::OrphanKey { line, key } => {
write!(f, "IR key '{key}' at line {line} is not inside a table")
}
IrError::NothingToCombine => write!(f, "no IR files to combine"),
}
}
}
impl std::error::Error for IrError {}
impl Ir {
/// Build an IR from a git [`ChangeSet`] (dropping the diff, which is not
/// part of the reviewable representation).
#[must_use]
pub fn from_changeset(changes: &ChangeSet) -> Self {
Ir {
from: changes.from.clone(),
to: changes.to.clone(),
entries: changes.commits.iter().map(Entry::from).collect(),
}
}
/// Serialise this IR to TOML.
#[must_use]
pub fn to_toml(&self) -> String {
let mut s = String::new();
s.push_str("[meta]\n");
let _ = writeln!(s, "from = \"{}\"", escape(&self.from));
let _ = writeln!(s, "to = \"{}\"", escape(&self.to));
for e in &self.entries {
s.push_str("\n[[commits]]\n");
let _ = writeln!(s, "short_hash = \"{}\"", escape(&e.short_hash));
let _ = writeln!(s, "summary = \"{}\"", escape(&e.summary));
let _ = writeln!(s, "body = \"{}\"", escape(&e.body));
let _ = writeln!(s, "author = \"{}\"", escape(&e.author));
}
s
}
/// Parse an IR from TOML text produced by [`Ir::to_toml`] (or a compatible
/// hand-edited file).
pub fn from_toml(input: &str) -> Result<Ir, IrError> {
let mut from: Option<String> = None;
let mut to: Option<String> = None;
let mut entries: Vec<Entry> = Vec::new();
let mut section = Section::None;
let mut current: Option<Entry> = None;
for (idx, raw) in input.lines().enumerate() {
let lineno = idx + 1;
let line = strip_comment(raw).trim();
if line.is_empty() {
continue;
}
if line == "[meta]" {
if let Some(e) = current.take() {
entries.push(e);
}
section = Section::Meta;
} else if line == "[[commits]]" {
if let Some(e) = current.take() {
entries.push(e);
}
current = Some(Entry::default());
section = Section::Commit;
} else if line.starts_with('[') {
return Err(IrError::MalformedLine {
line: lineno,
message: format!("unknown table header: {line}"),
});
} else {
let (key, value) = parse_kv(line, lineno)?;
match section {
Section::Meta => match key.as_str() {
"from" => from = Some(value),
"to" => to = Some(value),
_ => {} // ignore unknown meta keys (forward-compatible)
},
Section::Commit => {
let e = current.as_mut().expect("commit section has an entry");
match key.as_str() {
"short_hash" => e.short_hash = value,
"summary" => e.summary = value,
"body" => e.body = value,
"author" => e.author = value,
_ => {} // ignore unknown commit keys
}
}
Section::None => {
return Err(IrError::OrphanKey { line: lineno, key });
}
}
}
}
if let Some(e) = current.take() {
entries.push(e);
}
Ok(Ir {
from: from.ok_or(IrError::MissingMeta("from"))?,
to: to.ok_or(IrError::MissingMeta("to"))?,
entries,
})
}
/// Combine multiple IRs, in order, into a single IR.
///
/// The combined range spans from the first IR's `from` to the last IR's
/// `to`, and all entries are concatenated in the order supplied.
pub fn combine(irs: Vec<Ir>) -> Result<Ir, IrError> {
let mut iter = irs.into_iter();
let first = iter.next().ok_or(IrError::NothingToCombine)?;
let from = first.from;
let mut to = first.to;
let mut entries = first.entries;
for ir in iter {
to = ir.to;
entries.extend(ir.entries);
}
Ok(Ir { from, to, entries })
}
}
/// Which table the parser is currently reading keys into.
enum Section {
None,
Meta,
Commit,
}
/// Escape a string for inclusion in a TOML basic (double-quoted) string.
fn escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}
/// Strip an unquoted `#` comment from a line, respecting string quoting.
fn strip_comment(line: &str) -> &str {
let mut in_str = false;
let mut escaped = false;
for (i, c) in line.char_indices() {
if in_str {
if escaped {
escaped = false;
} else if c == '\\' {
escaped = true;
} else if c == '"' {
in_str = false;
}
} else if c == '"' {
in_str = true;
} else if c == '#' {
return &line[..i];
}
}
line
}
/// Parse a `key = "value"` line into its key and unescaped value.
fn parse_kv(line: &str, lineno: usize) -> Result<(String, String), IrError> {
let eq = line.find('=').ok_or_else(|| IrError::MalformedLine {
line: lineno,
message: format!("expected 'key = \"value\"', found: {line}"),
})?;
let key = line[..eq].trim().to_string();
if key.is_empty() {
return Err(IrError::MalformedLine {
line: lineno,
message: "empty key".to_string(),
});
}
let value = parse_basic_string(line[eq + 1..].trim(), lineno)?;
Ok((key, value))
}
/// Parse and unescape a TOML basic (double-quoted) string.
fn parse_basic_string(s: &str, lineno: usize) -> Result<String, IrError> {
let malformed = |message: String| IrError::MalformedLine {
line: lineno,
message,
};
if s.len() < 2 || !s.starts_with('"') || !s.ends_with('"') {
return Err(malformed(format!("expected a quoted string, found: {s}")));
}
let inner = &s[1..s.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c == '"' {
// An unescaped quote inside means the string ended early.
return Err(malformed(
"unexpected unescaped '\"' inside string".to_string(),
));
}
if c == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some('\\') => out.push('\\'),
Some('"') => out.push('"'),
Some(other) => out.push(other), // lenient: keep unknown escapes as-is
None => return Err(malformed("trailing backslash in string".to_string())),
}
} else {
out.push(c);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Ir {
Ir {
from: "v1".to_string(),
to: "v2".to_string(),
entries: vec![
Entry {
short_hash: "abc12345".to_string(),
summary: "feat: add greeting".to_string(),
body: "More detail.\nSecond line.".to_string(),
author: "Tester".to_string(),
},
Entry {
short_hash: "def67890".to_string(),
summary: r#"fix: handle "quoted" input"#.to_string(),
body: String::new(),
author: "Ann".to_string(),
},
],
}
}
#[test]
fn toml_roundtrips() {
let ir = sample();
let toml = ir.to_toml();
let parsed = Ir::from_toml(&toml).expect("parse");
assert_eq!(ir, parsed);
}
#[test]
fn toml_escapes_quotes_and_newlines() {
let toml = sample().to_toml();
assert!(toml.contains(r#"summary = "fix: handle \"quoted\" input""#));
assert!(toml.contains(r#"body = "More detail.\nSecond line.""#));
}
#[test]
fn parse_tolerates_comments_and_blank_lines() {
let input = "\
# a comment\n\
[meta]\n\
from = \"v1\" # inline comment\n\
\n\
to = \"v2\"\n\
\n\
[[commits]]\n\
short_hash = \"abc\"\n\
summary = \"do a thing # not a comment\"\n\
body = \"\"\n\
author = \"Me\"\n";
let ir = Ir::from_toml(input).expect("parse");
assert_eq!(ir.from, "v1");
assert_eq!(ir.to, "v2");
assert_eq!(ir.entries.len(), 1);
assert_eq!(ir.entries[0].summary, "do a thing # not a comment");
}
#[test]
fn missing_meta_errors() {
let input = "[meta]\nfrom = \"v1\"\n";
let err = Ir::from_toml(input).unwrap_err();
assert_eq!(err, IrError::MissingMeta("to"));
}
#[test]
fn orphan_key_errors() {
let input = "from = \"v1\"\n";
let err = Ir::from_toml(input).unwrap_err();
assert!(matches!(err, IrError::OrphanKey { .. }));
}
#[test]
fn unknown_table_errors() {
let input = "[meta]\nfrom=\"v1\"\nto=\"v2\"\n[weird]\nx=\"y\"\n";
let err = Ir::from_toml(input).unwrap_err();
assert!(matches!(err, IrError::MalformedLine { .. }));
}
#[test]
fn combine_spans_range_and_concatenates() {
let a = Ir {
from: "v1".to_string(),
to: "v2".to_string(),
entries: vec![Entry {
short_hash: "a".to_string(),
summary: "first".to_string(),
..Entry::default()
}],
};
let b = Ir {
from: "v2".to_string(),
to: "v3".to_string(),
entries: vec![Entry {
short_hash: "b".to_string(),
summary: "second".to_string(),
..Entry::default()
}],
};
let combined = Ir::combine(vec![a, b]).expect("combine");
assert_eq!(combined.from, "v1");
assert_eq!(combined.to, "v3");
assert_eq!(combined.entries.len(), 2);
assert_eq!(combined.entries[0].summary, "first");
assert_eq!(combined.entries[1].summary, "second");
}
#[test]
fn combine_empty_errors() {
assert_eq!(Ir::combine(vec![]).unwrap_err(), IrError::NothingToCombine);
}
#[test]
fn from_changeset_drops_diff() {
let cs = ChangeSet {
from: "v1".to_string(),
to: "v2".to_string(),
commits: vec![CommitInfo {
short_hash: "abc".to_string(),
summary: "s".to_string(),
body: "b".to_string(),
author: "a".to_string(),
}],
diff: "lots of diff".to_string(),
};
let ir = Ir::from_changeset(&cs);
assert_eq!(ir.entries.len(), 1);
assert_eq!(ir.entries[0].summary, "s");
}
}