1use std::collections::BTreeMap;
19use std::path::Path;
20
21use serde::{Deserialize, Serialize};
22use thiserror::Error;
23
24use crate::build_spec::{BuildSpec, PackageKind};
25
26pub 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct GoGenDelta {
51 pub schema_version: u32,
52 pub go_sum_sha256: String,
54 pub source_hashes: BTreeMap<String, String>,
58}
59
60impl GoGenDelta {
61 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
84pub 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}