//! 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,
})
}