Skip to content
//! 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");
    }
}