jacquardSnapshot

← snapshot

9140 bytes
//! The in-memory bridge adapter.

use std::collections::BTreeMap;
use std::str::FromStr as _;

use jac_core::{AgentId, Author, HumanId, Provenance, RepoPath, SnapshotId, Timestamp};
use jac_object::{ObjectStore, Segment, Snapshot, Tree, TreeEntry, TreeNode};
use smallvec::SmallVec;

use crate::image::{GitCommitImage, TrailerKey};
use crate::{GitError, GitInterop};

/// In-memory `GitInterop` adapter.
///
/// Proves round-trip fidelity: `export` then `import` yields the identical
/// snapshot id, because every field of a snapshot's hashed identity either
/// survives in the manifest or rides in a trailer.
#[derive(Debug, Default)]
pub struct FakeGit;

impl FakeGit {
    /// A fresh adapter.
    #[must_use]
    pub const fn new() -> Self {
        Self
    }
}

/// Flattens a tree into `(full path, blob id)` pairs.
fn flatten(
    store: &dyn ObjectStore,
    tree: &Tree,
    prefix: Option<&RepoPath>,
    out: &mut Vec<(RepoPath, jac_core::BlobId)>,
) -> Result<(), GitError> {
    for entry in tree.entries() {
        let path = match prefix {
            Some(p) => p
                .join(entry.name.as_str())
                .map_err(|_| GitError::InvalidPath)?,
            None => RepoPath::parse(entry.name.as_str()).map_err(|_| GitError::InvalidPath)?,
        };
        match entry.node {
            TreeNode::Blob(id) => out.push((path, id)),
            TreeNode::Tree(id) => {
                let sub = store.tree(id)?;
                flatten(store, sub, Some(&path), out)?;
            }
            // A node kind this bridge does not know cannot be exported
            // faithfully; refusing is the only honest answer.
            _ => {
                return Err(GitError::MalformedImage {
                    reason: "unrecognised tree node kind",
                });
            }
        }
    }
    Ok(())
}

/// Rebuilds a nested tree from a sorted manifest, writing trees into the
/// store, returning the root tree id.
fn plant(
    store: &mut dyn ObjectStore,
    manifest: &[(RepoPath, jac_core::BlobId)],
) -> Result<jac_core::TreeId, GitError> {
    // Group by first segment; leaves become blob entries, groups recurse.
    #[derive(Default)]
    struct Node {
        blobs: Vec<(String, jac_core::BlobId)>,
        children: BTreeMap<String, Vec<(String, jac_core::BlobId)>>,
    }

    fn build(
        store: &mut dyn ObjectStore,
        items: &[(String, jac_core::BlobId)],
    ) -> Result<jac_core::TreeId, GitError> {
        let mut node = Node::default();
        for (path, blob) in items {
            match path.split_once('/') {
                None => node.blobs.push((path.clone(), *blob)),
                Some((head, rest)) => node
                    .children
                    .entry(head.to_owned())
                    .or_default()
                    .push((rest.to_owned(), *blob)),
            }
        }
        let mut entries = Vec::new();
        for (name, blob) in node.blobs {
            entries.push(TreeEntry {
                name: Segment::parse(&name).map_err(|_| GitError::InvalidPath)?,
                node: TreeNode::Blob(blob),
            });
        }
        for (name, inner) in node.children {
            let sub = build(store, &inner)?;
            entries.push(TreeEntry {
                name: Segment::parse(&name).map_err(|_| GitError::InvalidPath)?,
                node: TreeNode::Tree(sub),
            });
        }
        let tree = Tree::new(entries).map_err(|_| GitError::InvalidPath)?;
        Ok(store.put_tree(tree))
    }

    let items: Vec<(String, jac_core::BlobId)> = manifest
        .iter()
        .map(|(p, b)| (p.as_str().to_owned(), *b))
        .collect();
    build(store, &items)
}

fn parse_author(s: &str) -> Result<Author, GitError> {
    let malformed = GitError::MalformedImage {
        reason: "unparseable Jacquard-Author trailer",
    };
    match s.split_once(':') {
        Some(("human", id)) => Ok(Author::Human(HumanId::from_str(id).map_err(|_| malformed)?)),
        Some(("agent", id)) => Ok(Author::Agent(AgentId::from_str(id).map_err(|_| malformed)?)),
        _ => Err(malformed),
    }
}

fn render_author(author: Author) -> String {
    match author {
        Author::Human(id) => format!("human:{id}"),
        Author::Agent(id) => format!("agent:{id}"),
        _ => "unrecognised".to_owned(),
    }
}

impl GitInterop for FakeGit {
    fn export(
        &mut self,
        id: SnapshotId,
        store: &dyn ObjectStore,
    ) -> Result<GitCommitImage, GitError> {
        let snapshot = store.snapshot(id)?;
        let tree = store.tree(snapshot.tree)?;
        let mut manifest = Vec::new();
        flatten(store, tree, None, &mut manifest)?;
        manifest.sort();

        Ok(GitCommitImage {
            manifest,
            parents: snapshot.parents.to_vec(),
            message: snapshot.message.clone(),
            trailers: vec![
                (
                    TrailerKey::Provenance,
                    snapshot.provenance.label().to_owned(),
                ),
                (TrailerKey::Author, render_author(snapshot.author)),
                (TrailerKey::Timestamp, snapshot.at.as_millis().to_string()),
            ],
        })
    }

    fn import(
        &mut self,
        image: &GitCommitImage,
        store: &mut dyn ObjectStore,
    ) -> Result<SnapshotId, GitError> {
        let tree = plant(store, &image.manifest)?;

        let provenance = image
            .trailer(TrailerKey::Provenance)
            .and_then(Provenance::parse_label)
            .ok_or(GitError::MalformedImage {
                reason: "missing or unparseable Jacquard-Provenance trailer",
            })?;
        let author = parse_author(image.trailer(TrailerKey::Author).ok_or(
            GitError::MalformedImage {
                reason: "missing Jacquard-Author trailer",
            },
        )?)?;
        let at = image
            .trailer(TrailerKey::Timestamp)
            .and_then(|s| s.parse::<i64>().ok())
            .map(Timestamp::from_millis)
            .ok_or(GitError::MalformedImage {
                reason: "missing or unparseable Jacquard-Timestamp trailer",
            })?;

        let snapshot = Snapshot {
            tree,
            parents: SmallVec::from_slice(&image.parents),
            author,
            provenance,
            message: image.message.clone(),
            at,
        };
        Ok(store.put_snapshot(snapshot))
    }
}

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "test assertions read better with unwrap"
)]
mod tests {
    use jac_core::AgentId;
    use jac_object::{Blob, MemoryObjectStore};

    use super::*;

    fn sample_snapshot(store: &mut MemoryObjectStore) -> SnapshotId {
        let blob = store.put_blob(Blob::new(&b"fn retry() {}"[..]));
        let inner = store.put_tree(
            Tree::new(vec![TreeEntry {
                name: Segment::parse("backoff.rs").unwrap(),
                node: TreeNode::Blob(blob),
            }])
            .unwrap(),
        );
        let root = store.put_tree(
            Tree::new(vec![TreeEntry {
                name: Segment::parse("auth").unwrap(),
                node: TreeNode::Tree(inner),
            }])
            .unwrap(),
        );
        store.put_snapshot(Snapshot {
            tree: root,
            parents: SmallVec::new(),
            author: Author::Agent(AgentId::new(4).unwrap()),
            provenance: Provenance::Agent,
            message: "add retry backoff".to_owned(),
            at: Timestamp::from_millis(42),
        })
    }

    #[test]
    fn round_trip_preserves_identity() {
        let mut store = MemoryObjectStore::new();
        let id = sample_snapshot(&mut store);

        let mut bridge = FakeGit::new();
        let image = bridge.export(id, &store).unwrap();
        let back = bridge.import(&image, &mut store).unwrap();

        assert_eq!(
            back, id,
            "provenance, author, and timestamp must survive the trip in trailers"
        );
    }

    #[test]
    fn trailers_carry_provenance_in_git_convention() {
        let mut store = MemoryObjectStore::new();
        let id = sample_snapshot(&mut store);
        let image = FakeGit::new().export(id, &store).unwrap();

        assert_eq!(image.trailer(TrailerKey::Provenance), Some("agent"));
        assert!(image.full_message().contains("Jacquard-Provenance: agent"));
    }

    #[test]
    fn a_tampered_provenance_trailer_changes_the_imported_id() {
        let mut store = MemoryObjectStore::new();
        let id = sample_snapshot(&mut store);
        let mut bridge = FakeGit::new();
        let mut image = bridge.export(id, &store).unwrap();

        // Relabel the agent's work as human in the exported image.
        for (key, value) in &mut image.trailers {
            if *key == TrailerKey::Provenance {
                "human".clone_into(value);
            }
        }
        let relabelled = bridge.import(&image, &mut store).unwrap();
        assert_ne!(
            relabelled, id,
            "a relabelled import is a different snapshot, visibly"
        );
    }
}