jacquardSnapshot

← snapshot

6103 bytes
//! A jacquard repository on disk.
//!
//! `~/jack/one/<name>/` is to jacquard what `~/github/one/<name>/` is to git:
//! the place a repository lives. Milestone-1's engine holds everything in
//! memory, so "living on disk" here means a directory this module can write a
//! repo out to and read a repo back in from — not a new storage engine behind
//! the engine.
//!
//! ```text
//! <root>/<name>/
//!   manifest.json    what it is, where it came from, what bounds applied
//!   snapshots.json   the chain, each with its provenance and why
//!   decisions.json   proposals and the attestations that settled them
//!   objects/<hex>    blob bytes, addressed by the engine's own digest
//! ```
//!
//! The addresses are the engine's, not git's, and that is load-bearing: a
//! jacquard snapshot id has provenance hashed into it, so the same tree under
//! different hands lands on a different address. Reading a repo back
//! **reproduces the same ids** — which is the only real test that what was
//! written is what the engine meant. [`load`] checks exactly that and says so
//! when it does not hold.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use jac_core::{Author, HumanId, HumanIdentity, Provenance, SystemClock};
use jac_decision::MemoryLedger;
use jac_object::MemoryObjectStore;
use jac_repo::{MemoryRefStore, RefName, Repo};
use serde_json::Value;

use crate::state::ServeRepo;

/// The manifest's `kind`, so a reader can tell what it is holding.
pub const KIND: &str = "jacquard-repo/1";

/// A repository read back off disk.
#[derive(Debug)]
pub struct Loaded {
    /// Directory name, used as the slug.
    pub name: String,
    /// The live engine, replayed from what was stored.
    pub repo: ServeRepo,
    /// The person the repo is attributed to. Declared, never authenticated.
    pub founder: HumanIdentity,
    /// Where the original git working copy was, when it came from one.
    pub source: Option<String>,
    /// Snapshots whose replayed address did not match what was recorded.
    ///
    /// Non-empty means the file and the engine disagree about what this
    /// history is. That is worth surfacing loudly rather than papering over:
    /// it means either the file was edited by hand or the engine changed how
    /// it addresses things.
    pub mismatched: Vec<String>,
}

/// Every directory under `root` that holds a jacquard repo.
#[must_use]
pub fn discover(root: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    let Ok(entries) = std::fs::read_dir(root) else {
        return out;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.join("manifest.json").exists() && path.join("snapshots.json").exists() {
            out.push(path);
        }
    }
    out.sort();
    out
}

fn provenance_of(label: &str) -> Provenance {
    match label {
        "agent" => Provenance::Agent,
        "mixed" => Provenance::Mixed,
        // Fail toward the weaker claim: an unrecognised label is not evidence
        // that a person made this.
        _ => Provenance::Human,
    }
}

/// Reads one repository back into a live engine.
///
/// # Errors
/// When the directory is not a jacquard repo, its files cannot be read or
/// parsed, or an object a snapshot names is missing.
pub fn load(dir: &Path) -> anyhow::Result<Loaded> {
    let name = dir
        .file_name()
        .map_or_else(|| "repo".to_owned(), |n| n.to_string_lossy().into_owned());

    let manifest: Value = serde_json::from_slice(&std::fs::read(dir.join("manifest.json"))?)?;
    let snapshots: Vec<Value> =
        serde_json::from_slice(&std::fs::read(dir.join("snapshots.json"))?)?;

    let mut repo = Repo::new(
        MemoryObjectStore::new(),
        MemoryRefStore::new(),
        MemoryLedger::new(),
        SystemClock,
    );
    let main = RefName::parse("main")?;
    let founder = HumanIdentity {
        id: HumanId::from_non_zero(core::num::NonZeroU64::MIN),
        display_name: manifest["founder"]
            .as_str()
            .unwrap_or("imported")
            .to_owned(),
    };

    // Blobs are read lazily and cached: a repo with twelve snapshots over the
    // same files names each object many times.
    let objects = dir.join("objects");
    let mut cache: BTreeMap<String, Vec<u8>> = BTreeMap::new();
    let mut mismatched = Vec::new();

    for snapshot in &snapshots {
        let files = snapshot["files"].as_array().cloned().unwrap_or_default();
        let mut owned: Vec<(String, Vec<u8>)> = Vec::new();
        for file in &files {
            let (Some(path), Some(blob)) = (file["path"].as_str(), file["blob"].as_str()) else {
                continue;
            };
            let bytes = if let Some(hit) = cache.get(blob) {
                hit.clone()
            } else {
                let read = std::fs::read(objects.join(blob))
                    .map_err(|e| anyhow::anyhow!("{name}: object {blob} is missing: {e}"))?;
                cache.insert(blob.to_owned(), read.clone());
                read
            };
            owned.push((path.to_owned(), bytes));
        }
        if owned.is_empty() {
            continue;
        }

        let refs: Vec<(&str, &[u8])> = owned
            .iter()
            .map(|(p, c)| (p.as_str(), c.as_slice()))
            .collect();
        let id = repo.commit(
            &main,
            &refs,
            Author::Human(founder.id),
            provenance_of(snapshot["provenance"].as_str().unwrap_or("human")),
            snapshot["message"].as_str().unwrap_or(""),
        )?;

        // The address must come out the same. If it does not, the file and
        // the engine disagree about what this history is.
        let replayed = id.digest().to_hex().as_str().to_owned();
        if let Some(recorded) = snapshot["id"].as_str()
            && recorded != replayed
        {
            mismatched.push(recorded.to_owned());
        }
    }

    Ok(Loaded {
        name,
        repo,
        founder,
        source: manifest["source"].as_str().map(str::to_owned),
        mismatched,
    })
}