git-vet 0.2.0

Git-based review gate for tracked file contents
Documentation
use std::collections::HashMap;

use serde::{Deserialize, Serialize};

use crate::git_types::{BlobOid, CommitOid};
use crate::path::RepoPath;

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub(crate) struct Vetter {
    pub(crate) name: String,
    pub(crate) email: String,
}

impl Vetter {
    pub(crate) const fn new(name: String, email: String) -> Self {
        Self { name, email }
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ReviewRecord {
    pub(crate) vetted_at: String,
    pub(crate) vetted_by: Vetter,
    pub(crate) commit: CommitOid,
    pub(crate) path: RepoPath,
}

impl ReviewRecord {
    fn render(&self) -> Result<String, serde_json::Error> {
        serde_json::to_string(&NoteRecord::from(self))
    }
}

#[derive(Clone, Debug, Default)]
pub(crate) struct ReviewInfo {
    pub(crate) records: Vec<ReviewRecord>,
}

impl ReviewInfo {
    fn latest_metadata(&self) -> Option<ReviewMetadata> {
        self.records
            .iter()
            .max_by(|left, right| left.vetted_at.cmp(&right.vetted_at))
            .map(|record| ReviewMetadata {
                last_vetted_at: record.vetted_at.clone(),
                vetted_by: record.vetted_by.clone(),
            })
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct ReviewMetadata {
    pub(crate) last_vetted_at: String,
    pub(crate) vetted_by: Vetter,
}

#[derive(Clone, Debug, Default)]
pub(crate) struct ReviewedSet {
    pub(crate) by_blob: HashMap<BlobOid, ReviewInfo>,
}

impl ReviewedSet {
    pub(crate) fn is_empty(&self) -> bool {
        self.by_blob.is_empty()
    }

    pub(crate) fn contains(&self, oid: &BlobOid) -> bool {
        self.by_blob.contains_key(oid)
    }

    pub(crate) fn metadata(&self, oid: &BlobOid) -> Option<ReviewMetadata> {
        self.by_blob.get(oid).and_then(ReviewInfo::latest_metadata)
    }
}

#[derive(Clone, Debug)]
pub(crate) enum ReviewState {
    Vetted,
    Stale { baseline: BlobOid },
    New,
}

impl ReviewState {
    pub(crate) const fn as_str(&self) -> &'static str {
        match self {
            Self::Vetted => "vetted",
            Self::Stale { .. } => "stale",
            Self::New => "new",
        }
    }

    pub(crate) const fn baseline(&self) -> Option<&BlobOid> {
        match self {
            Self::Stale { baseline } => Some(baseline),
            Self::Vetted | Self::New => None,
        }
    }
}

#[derive(Clone, Debug)]
pub(crate) struct ClassifiedFile {
    pub(crate) path: RepoPath,
    pub(crate) state: ReviewState,
    pub(crate) blob: BlobOid,
    pub(crate) metadata: Option<ReviewMetadata>,
}

pub(crate) fn append_record(
    existing: Option<&str>,
    new_record: &ReviewRecord,
) -> Result<String, serde_json::Error> {
    let already_recorded = existing
        .map(parse_note_records)
        .unwrap_or_default()
        .iter()
        .any(|record| {
            record.vetted_by == new_record.vetted_by
                && record.commit == new_record.commit
                && record.path == new_record.path
        });

    let mut lines = existing
        .into_iter()
        .flat_map(str::lines)
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .map(ToOwned::to_owned)
        .collect::<Vec<_>>();
    if !already_recorded {
        lines.push(new_record.render()?);
    }
    lines.sort();
    lines.dedup();

    if lines.is_empty() {
        Ok(String::new())
    } else {
        Ok(format!("{}\n", lines.join("\n")))
    }
}

pub(crate) fn parse_note_records(body: &str) -> Vec<ReviewRecord> {
    body.lines().filter_map(parse_note_record).collect()
}

fn parse_note_record(line: &str) -> Option<ReviewRecord> {
    serde_json::from_str::<NoteRecord>(line)
        .ok()
        .and_then(NoteRecord::into_review_record)
}

#[derive(Deserialize, Serialize)]
struct NoteRecord {
    vetted_at: String,
    vetted_by: Vetter,
    commit: String,
    path: String,
}

impl From<&ReviewRecord> for NoteRecord {
    fn from(record: &ReviewRecord) -> Self {
        Self {
            vetted_at: record.vetted_at.clone(),
            vetted_by: record.vetted_by.clone(),
            commit: record.commit.to_string(),
            path: record.path.to_string(),
        }
    }
}

impl NoteRecord {
    fn into_review_record(self) -> Option<ReviewRecord> {
        Some(ReviewRecord {
            vetted_at: self.vetted_at,
            vetted_by: self.vetted_by,
            commit: CommitOid::new(gix::ObjectId::from_hex(self.commit.as_bytes()).ok()?),
            path: RepoPath::from_git_path(&self.path).ok()?,
        })
    }
}

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

    fn oid(hex: &str) -> Result<gix::ObjectId, Box<dyn std::error::Error>> {
        gix::ObjectId::from_hex(hex.as_bytes()).map_err(Into::into)
    }

    #[test]
    fn append_record_sorts_and_deduplicates_records() -> Result<(), Box<dyn std::error::Error>> {
        let record = ReviewRecord {
            vetted_at: "2026-06-06T00:00:00Z".to_owned(),
            vetted_by: Vetter::new("Reviewer".to_owned(), "reviewer@example.com".to_owned()),
            commit: CommitOid::new(oid("0123456789012345678901234567890123456789")?),
            path: RepoPath::from_git_path("src/main.rs")?,
        };
        let existing = "{\"vetted_at\":\"2026-06-06T00:00:00Z\",\"vetted_by\":{\"name\":\"Reviewer\",\"email\":\"reviewer@example.com\"},\"commit\":\"0123456789012345678901234567890123456789\",\"path\":\"src/main.rs\"}\n";

        assert_eq!(append_record(Some(existing), &record)?, existing);
        Ok(())
    }
}