quinjet 0.0.46

A fast, live, keyboard-first Git source-control interface for the terminal
use serde::Serialize;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct Commit {
    pub id: String,
    pub short_id: String,
    pub parent_ids: Vec<String>,
    pub author: String,
    pub author_email: String,
    pub authored_at: String,
    pub committer: String,
    pub committer_email: String,
    pub committed_at: String,
    pub relative_date: String,
    pub subject: String,
    pub decorations: Vec<String>,
}

#[doc = " The format passed to `git log`. Unit and record separators avoid ambiguity with"]
#[doc = " spaces, tabs, and most text that can occur in names or commit subjects."]
pub(crate) const LOG_FORMAT: &str =
    "%H%x1f%h%x1f%P%x1f%aN%x1f%aE%x1f%aI%x1f%cN%x1f%cE%x1f%cI%x1f%ar%x1f%s%x1f%D%x1e";

pub(crate) fn parse_log(output: &[u8]) -> Vec<Commit> {
    output
        .split(|byte| *byte == 0x1e)
        .filter_map(parse_record)
        .collect()
}

fn parse_record(record: &[u8]) -> Option<Commit> {
    let record = trim_ascii(record);
    if record.is_empty() {
        return None;
    }
    let fields: Vec<&[u8]> = record.split(|byte| *byte == 0x1f).collect();
    let [
        id,
        short_id,
        parent_ids,
        author,
        author_email,
        authored_at,
        committer,
        committer_email,
        committed_at,
        relative_date,
        subject,
        decorations,
        ..,
    ] = fields.as_slice()
    else {
        return None;
    };

    Some(Commit {
        id: text(id),
        short_id: text(short_id),
        parent_ids: text(parent_ids)
            .split_ascii_whitespace()
            .map(str::to_owned)
            .collect(),
        author: text(author),
        author_email: text(author_email),
        authored_at: text(authored_at),
        committer: text(committer),
        committer_email: text(committer_email),
        committed_at: text(committed_at),
        relative_date: text(relative_date),
        subject: text(subject),
        decorations: text(decorations)
            .split(',')
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .map(str::to_owned)
            .collect(),
    })
}

const fn trim_ascii(mut value: &[u8]) -> &[u8] {
    while let Some((first, rest)) = value.split_first() {
        if !first.is_ascii_whitespace() {
            break;
        }
        value = rest;
    }
    while let Some((last, rest)) = value.split_last() {
        if !last.is_ascii_whitespace() {
            break;
        }
        value = rest;
    }
    value
}

fn text(value: &[u8]) -> String {
    String::from_utf8_lossy(value).into_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parses_log_records_and_decorations() {
        let output = b"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\x1faaaaaaa\x1fbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb cccccccccccccccccccccccccccccccccccccccc\x1fAda Lovelace\x1fada@example.com\x1f2026-01-02T03:04:05Z\x1fLinus Torvalds\x1flinus@example.com\x1f2026-01-02T04:05:06Z\x1f2 hours ago\x1fMerge a fast thing\x1fHEAD -> main, origin/main, tag: v1\x1e\nbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\x1fbbbbbbb\x1f\x1fGrace Hopper\x1fgrace@example.com\x1f2026-01-01T00:00:00Z\x1fGrace Hopper\x1fgrace@example.com\x1f2026-01-01T00:00:01Z\x1fyesterday\x1fInitial commit\x1f\x1e";

        let commits = parse_log(output);

        assert_eq!(commits.len(), 2);
        assert_eq!(commits[0].author, "Ada Lovelace");
        assert_eq!(commits[0].committer, "Linus Torvalds");
        assert_eq!(commits[0].committed_at, "2026-01-02T04:05:06Z");
        assert_eq!(commits[0].parent_ids.len(), 2);
        assert_eq!(
            commits[0].decorations,
            vec!["HEAD -> main", "origin/main", "tag: v1"]
        );
        assert_eq!(commits[1].subject, "Initial commit");
        assert_eq!(commits[1].parent_ids, Vec::<String>::new());
    }

    #[test]
    fn skips_incomplete_records_without_losing_valid_neighbors() {
        let first = record("one", "", "HEAD -> main", b"");
        let second = record("two", "parent-a parent-b", "tag: v2", b"");
        let mut output = first;
        output.extend_from_slice(b"broken\x1frecord\x1e");
        output.extend_from_slice(&second);

        let commits = parse_log(&output);

        assert_eq!(commits.len(), 2);
        assert_eq!(commits[0].subject, "one");
        assert_eq!(commits[1].subject, "two");
        assert_eq!(commits[1].parent_ids, ["parent-a", "parent-b"]);
    }

    #[test]
    fn accepts_extra_fields_and_a_final_record_without_separator() {
        let output = record("subject", "parent", "main", b"\x1fignored");
        let without_separator = output.strip_suffix(b"\x1e").unwrap();

        for value in [output.as_slice(), without_separator] {
            let commits = parse_log(value);
            assert_eq!(commits.len(), 1);
            assert_eq!(commits[0].subject, "subject");
            assert_eq!(commits[0].parent_ids, ["parent"]);
        }
    }

    #[test]
    fn trims_decorations_and_decodes_invalid_utf8_lossily() {
        let output = record("bad-\u{fffd}", "", " main, , tag: v1,  ", b"");
        let mut bytes = output;
        let position = bytes
            .windows("bad-\u{fffd}".len())
            .position(|window| window == "bad-\u{fffd}".as_bytes())
            .unwrap();
        drop(bytes.splice(
            position..position + "bad-\u{fffd}".len(),
            b"bad-\xff".iter().copied(),
        ));

        let commits = parse_log(&bytes);

        assert_eq!(commits.len(), 1);
        assert_eq!(commits[0].subject, "bad-\u{fffd}");
        assert_eq!(commits[0].decorations, ["main", "tag: v1"]);
    }

    #[test]
    fn empty_and_whitespace_only_streams_have_no_commits() {
        for output in [b"".as_slice(), b" \n\t\x1e\r\n".as_slice()] {
            assert_eq!(parse_log(output), Vec::<Commit>::new());
        }
    }

    fn record(subject: &str, parents: &str, decorations: &str, suffix: &[u8]) -> Vec<u8> {
        let fields = [
            "0123456789abcdef0123456789abcdef01234567",
            "0123456",
            parents,
            "Author",
            "author@example.com",
            "2026-01-02T03:04:05Z",
            "Committer",
            "committer@example.com",
            "2026-01-02T04:05:06Z",
            "now",
            subject,
            decorations,
        ];
        let mut output = fields.join("\x1f").into_bytes();
        output.extend_from_slice(suffix);
        output.push(0x1e);
        output
    }
}