crates/nornir-build-thing/src/sink.rs
//! The **build-provenance sink** seam (mirrors `nornir_testmatrix::sink`).
//!
//! A [`BuildProvenanceSink`] receives a captured [`BuildProvenance`] record and
//! persists it. The record is written to the sink **first** (the warehouse
//! ledger), then dumped as a znippy archive beside the released artifact — this
//! crate defines the seam and two dep-light built-ins; nornir provides the
//! Iceberg-warehouse implementation (`nornir::warehouse::build_provenance`).
use anyhow::{Context, Result};
use crate::provenance::BuildProvenance;
/// Persist a build-provenance record. The heavy Iceberg implementation lives in
/// nornir; leaf repos can use [`NullSink`] / [`JsonFileSink`] without any
/// warehouse dependency.
pub trait BuildProvenanceSink {
/// Record one provenance entry (the warehouse ledger write).
fn record(&self, prov: &BuildProvenance) -> Result<()>;
}
/// Discards records — a no-op sink for callers that only want the local dump.
#[derive(Debug, Default, Clone, Copy)]
pub struct NullSink;
impl BuildProvenanceSink for NullSink {
fn record(&self, _prov: &BuildProvenance) -> Result<()> {
Ok(())
}
}
/// Appends each record as a JSON line to `dir/build_provenance.jsonl` — a
/// zero-dependency ledger for repos without a warehouse.
#[derive(Debug, Clone)]
pub struct JsonFileSink {
dir: std::path::PathBuf,
}
impl JsonFileSink {
/// Ledger under `dir` (created on first write).
pub fn new(dir: impl Into<std::path::PathBuf>) -> Self {
JsonFileSink { dir: dir.into() }
}
/// The ledger file path.
pub fn path(&self) -> std::path::PathBuf {
self.dir.join("build_provenance.jsonl")
}
}
impl BuildProvenanceSink for JsonFileSink {
fn record(&self, prov: &BuildProvenance) -> Result<()> {
use std::io::Write;
std::fs::create_dir_all(&self.dir)
.with_context(|| format!("create ledger dir {}", self.dir.display()))?;
let line = serde_json::to_string(prov).context("serialize provenance line")?;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path())
.with_context(|| format!("open ledger {}", self.path().display()))?;
writeln!(f, "{line}").context("append provenance line")?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provenance::{BuildProvenance, HostInfo, Outcome, Toolchain};
use crate::variant::BuildVariant;
fn sample() -> BuildProvenance {
let v = BuildVariant { name: "full".into(), ..Default::default() };
BuildProvenance::capture(
&v,
"holger",
std::path::Path::new("."),
Toolchain::default(),
HostInfo::default(),
Outcome::Success,
&[],
)
.unwrap()
}
#[test]
fn null_sink_is_a_noop() {
assert!(NullSink.record(&sample()).is_ok());
}
#[test]
fn json_file_sink_appends_a_line_per_record() {
let dir = tempfile::tempdir().unwrap();
let sink = JsonFileSink::new(dir.path());
sink.record(&sample()).unwrap();
sink.record(&sample()).unwrap();
let text = std::fs::read_to_string(sink.path()).unwrap();
assert_eq!(text.lines().count(), 2);
// Each line is a full, re-parseable record.
let first = BuildProvenance::from_json(text.lines().next().unwrap()).unwrap();
assert_eq!(first.repo, "holger");
}
}