Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}