Skip to main content

gen_gomod/
gen_delta.rs

1//! `Go.gen.lock` — the slim resolver-delta for the gomod ecosystem.
2//!
3//! Mirrors gen-cargo's `Cargo.gen.lock` shape (a `GenDeltaArtifact`
4//! tied to the source by a content hash), but carries what Go's build
5//! actually needs incrementally: the **per-node `source_hash`** (the
6//! incremental cache keys — a changed hash ⇒ rebuild that node + its
7//! dependents) and the **`go_sum_sha256`** freshness tie the substrate
8//! `go/lockfile-delta.nix` D2 gate already checks (empty-string hash
9//! `e3b0c4…` when dep-free).
10//!
11//! Deliberately slim: it restates neither the import graph nor the file
12//! lists (those live in `Go.build-spec.json`). Unlike Cargo — where the
13//! lockfile reconstructs the dep closure in pure Nix — Go's package
14//! graph is NOT reconstructable from `go.sum` (only `go list` resolves
15//! build constraints + vendor rewrites), so the graph stays in the full
16//! build-spec; this delta is purely the freshness + cache-key surface.
17
18use std::collections::BTreeMap;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::build_spec::{BuildSpec, PackageKind};
25
26/// Schema version of the slim delta artifact (distinct from the full
27/// spec's `SCHEMA_VERSION`).
28pub const DELTA_SCHEMA_VERSION: u32 = 1;
29
30#[derive(Debug, Error)]
31pub enum GenDeltaError {
32    #[error("gen-delta: BuildSpec has no go_sum_sha256 — the D2 freshness tie is mandatory")]
33    NoGoSumSha,
34    #[error("gen-delta: refusing to emit a delta with zero source-hashed nodes")]
35    EmptyNodes,
36    #[error("gen-delta: serialize Go.gen.lock: {0}")]
37    Serialize(#[from] serde_json::Error),
38    #[error("gen-delta: write {path}: {source}")]
39    Write {
40        path: String,
41        #[source]
42        source: std::io::Error,
43    },
44}
45
46/// `Go.gen.lock`. Keyed maps are `BTreeMap` ⇒ canonical (sorted) key
47/// order by construction, so the file is byte-identical across build
48/// hosts.
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct GoGenDelta {
51    pub schema_version: u32,
52    /// D2 freshness tie — lowercase hex SHA-256 of `go.sum`.
53    pub go_sum_sha256: String,
54    /// Per-node incremental cache keys: node key → BLAKE3 `source_hash`.
55    /// Std nodes (content-addressed by the std-tree, no per-file hash)
56    /// are omitted.
57    pub source_hashes: BTreeMap<String, String>,
58}
59
60impl GoGenDelta {
61    /// Distill from a full v2 build-spec. Errors rather than emit a
62    /// degenerate delta (missing tie / no hashed nodes).
63    pub fn distill(spec: &BuildSpec) -> Result<Self, GenDeltaError> {
64        let go_sum_sha256 = spec.go_sum_sha256.clone().ok_or(GenDeltaError::NoGoSumSha)?;
65        let source_hashes: BTreeMap<String, String> = spec
66            .packages
67            .iter()
68            .filter(|(_, p)| p.kind != PackageKind::Std && !p.source_hash.is_empty())
69            .map(|(k, p)| (k.clone(), p.source_hash.clone()))
70            .collect();
71        if source_hashes.is_empty() {
72            return Err(GenDeltaError::EmptyNodes);
73        }
74        Ok(GoGenDelta { schema_version: DELTA_SCHEMA_VERSION, go_sum_sha256, source_hashes })
75    }
76
77    pub fn to_json(&self) -> Result<String, GenDeltaError> {
78        Ok(serde_json::to_string_pretty(self)?)
79    }
80
81    pub const FILENAME: &'static str = "Go.gen.lock";
82}
83
84/// Distill + write `Go.gen.lock` next to the module's `go.mod`. Additive
85/// — call after the full build-spec write; a failed emit MUST fail
86/// `gen build` (never silently skipped).
87pub fn write_gen_delta(root: &Path, spec: &BuildSpec) -> Result<(), GenDeltaError> {
88    let delta = GoGenDelta::distill(spec)?;
89    let path = root.join(GoGenDelta::FILENAME);
90    std::fs::write(&path, delta.to_json()? + "\n").map_err(|source| GenDeltaError::Write {
91        path: path.display().to_string(),
92        source,
93    })
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::build_spec::{
100        BuildTree, DepMode, EmbedSpec, GoPackageArgs, ModuleSpec, PackageSource, PackageSpec,
101        Renderer, SCHEMA_VERSION,
102    };
103
104    fn node(kind: PackageKind, hash: &str) -> PackageSpec {
105        PackageSpec {
106            import_path: "example.com/x/a".into(),
107            kind,
108            source: if kind == PackageKind::Std {
109                PackageSource::Std
110            } else {
111                PackageSource::Vendored { relative_path: "a".into() }
112            },
113            tree: BuildTree::Target,
114            go_files: vec!["a.go".into()],
115            build_tags: vec![],
116            embed: EmbedSpec::default(),
117            imports: vec![],
118            import_map: BTreeMap::new(),
119            module: None,
120            source_hash: hash.into(),
121            args: GoPackageArgs::default(),
122            quirks: vec![],
123        }
124    }
125
126    fn spec_with(go_sum: Option<&str>) -> BuildSpec {
127        let mut packages = BTreeMap::new();
128        packages.insert("example.com/x/a#linux-amd64".to_string(), node(PackageKind::Module, "aaaa"));
129        packages.insert("std/fmt#linux-amd64".to_string(), node(PackageKind::Std, ""));
130        BuildSpec {
131            version: SCHEMA_VERSION,
132            renderer: Renderer::Incremental,
133            module: ModuleSpec {
134                module_path: "example.com/x".into(),
135                go_version: "1.25".into(),
136                toolchain: None,
137                has_external_deps: false,
138                dep_mode: DepMode::Vendored,
139                vendor_hash: None,
140            },
141            packages,
142            root_package: "example.com/x/a#linux-amd64".into(),
143            workspace_members: vec![],
144            target_resolves: None,
145            go_sum_sha256: go_sum.map(str::to_string),
146        }
147    }
148
149    #[test]
150    fn distill_carries_hashes_and_tie_omits_std() {
151        let d = GoGenDelta::distill(&spec_with(Some("deadbeef"))).unwrap();
152        assert_eq!(d.go_sum_sha256, "deadbeef");
153        assert_eq!(d.source_hashes.len(), 1, "std node omitted, module node kept");
154        assert!(d.source_hashes.contains_key("example.com/x/a#linux-amd64"));
155    }
156
157    #[test]
158    fn missing_go_sum_tie_is_refused() {
159        assert!(matches!(
160            GoGenDelta::distill(&spec_with(None)),
161            Err(GenDeltaError::NoGoSumSha)
162        ));
163    }
164
165    #[test]
166    fn roundtrip_is_lossless() {
167        let d = GoGenDelta::distill(&spec_with(Some("deadbeef"))).unwrap();
168        let json = d.to_json().unwrap();
169        let back: GoGenDelta = serde_json::from_str(&json).unwrap();
170        assert_eq!(d, back);
171        assert_eq!(json, back.to_json().unwrap());
172    }
173}