cratefield_cli/
build_key.rs1use crate::build::{load_catalog, parse};
21use cratefield_manifest::{BUILD_PROFILE, BuildKeyInputs, build_key, canonical_inputs};
22use std::path::Path;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct BuildKeyReport {
29 pub key: String,
31 pub inputs_json: String,
33}
34
35pub fn compute_with(
44 manifest_path: &Path,
45 catalog_path: Option<&Path>,
46 rustc_version: &str,
47) -> Result<BuildKeyReport, String> {
48 let raw = std::fs::read_to_string(manifest_path)
49 .map_err(|err| format!("cannot read {}: {err}", manifest_path.display()))?;
50 let manifest = parse(manifest_path, &raw)?;
51 let catalog = load_catalog(catalog_path)?;
52 let module_set = manifest.resolve(&catalog).map_err(|err| err.to_string())?;
53
54 let inputs = BuildKeyInputs {
55 releases: module_set.releases().to_vec(),
56 harness_api: cratefield_core::HARNESS_API,
57 rustc_version: rustc_version.to_owned(),
58 profile: BUILD_PROFILE.to_owned(),
59 };
60 let key = build_key(&inputs).map_err(|err| err.to_string())?;
61 Ok(BuildKeyReport {
62 key,
63 inputs_json: canonical_inputs(&inputs),
64 })
65}
66
67pub fn rustc_version() -> Result<String, String> {
77 let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_owned());
78 let output = std::process::Command::new(&rustc)
79 .arg("--version")
80 .output()
81 .map_err(|err| {
82 format!(
83 "cannot run {rustc} --version: {err} — the key names the \
84 compiler, so it cannot be computed without one"
85 )
86 })?;
87 if !output.status.success() {
88 return Err(format!(
89 "{rustc} --version failed: {}",
90 String::from_utf8_lossy(&output.stderr).trim()
91 ));
92 }
93 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
94}
95
96pub fn run(manifest_path: &Path, catalog_path: Option<&Path>) -> Result<(), String> {
104 let report = compute_with(manifest_path, catalog_path, &rustc_version()?)?;
105 println!("fz build-key: {}", report.key);
106 println!("{}", report.inputs_json);
107 println!();
108 println!(
109 "The sidecar mount table is not an input: two compositions differing only in \
110 HARNESS_SIDECARS share this key and the same artifact (issue #59, amended)."
111 );
112 Ok(())
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use std::path::PathBuf;
119
120 const RUSTC_TEST_VERSION: &str = "rustc 1.98.1 (test-fixture 2026-01-01)";
121
122 fn write_manifest(name: &str, modules: &str) -> PathBuf {
126 let path =
127 std::env::temp_dir().join(format!("fz-build-key-{}-{}.json", name, std::process::id()));
128 std::fs::write(
129 &path,
130 format!(r#"{{ "name": "acme", "host": "acme.factory0.dev", "modules": {modules} }}"#),
131 )
132 .expect("temp manifest writes");
133 path
134 }
135
136 #[test]
137 fn reordering_the_manifest_does_not_change_the_key() {
138 let a = write_manifest("order-a", r#"["waitlist", "email-signup"]"#);
139 let b = write_manifest("order-b", r#"["email-signup", "waitlist"]"#);
140 let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
141 let kb = compute_with(&b, None, RUSTC_TEST_VERSION).unwrap();
142 assert_eq!(ka, kb);
143 assert!(ka.key.starts_with("sha256:"));
144 let _ = std::fs::remove_file(a);
145 let _ = std::fs::remove_file(b);
146 }
147
148 #[test]
149 fn the_sidecar_mount_table_is_not_an_input() {
150 let manifest = write_manifest("sidecars", r#"["waitlist"]"#);
162 let first = compute_with(&manifest, None, RUSTC_TEST_VERSION).unwrap();
163 let second = compute_with(&manifest, None, RUSTC_TEST_VERSION).unwrap();
164 assert_eq!(first.key, second.key);
165 assert!(!first.inputs_json.contains("sidecar"));
166 assert!(!first.inputs_json.contains("mount"));
167 let _ = std::fs::remove_file(manifest);
168 }
169
170 #[test]
171 fn per_customer_configuration_is_not_an_input_either() {
172 let a =
177 std::env::temp_dir().join(format!("fz-build-key-cfg-a-{}.json", std::process::id()));
178 let b =
179 std::env::temp_dir().join(format!("fz-build-key-cfg-b-{}.json", std::process::id()));
180 std::fs::write(
181 &a,
182 r#"{ "name": "acme", "host": "acme.dev", "cors_origins": ["https://a.dev"], "config": { "brand": "A" }, "modules": ["waitlist"] }"#,
183 )
184 .expect("temp manifest writes");
185 std::fs::write(
186 &b,
187 r#"{ "name": "other", "host": "other.dev", "config": { "brand": "B" }, "modules": ["waitlist"] }"#,
188 )
189 .expect("temp manifest writes");
190 let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
191 let kb = compute_with(&b, None, RUSTC_TEST_VERSION).unwrap();
192 assert_eq!(ka.key, kb.key);
193 let _ = std::fs::remove_file(a);
194 let _ = std::fs::remove_file(b);
195 }
196
197 #[test]
198 fn changing_a_pin_changes_the_key() {
199 let builtin = crate::build::load_catalog(None).unwrap();
200 let version = builtin
201 .modules
202 .iter()
203 .find(|m| m.slug == "cms")
204 .unwrap()
205 .releases[0]
206 .version
207 .clone();
208
209 let a = write_manifest("pin-a", r#"["cms"]"#);
210 let ka = compute_with(&a, None, RUSTC_TEST_VERSION).unwrap();
211
212 let kb = compute_with(&a, None, "rustc 1.99.0 (other 2026-06-01)").unwrap();
214 assert_ne!(ka.key, kb.key);
215 assert!(ka.inputs_json.contains(&version));
216 let _ = std::fs::remove_file(a);
217 }
218
219 #[test]
220 fn an_unresolvable_manifest_is_refused_before_any_hashing() {
221 let ghost = write_manifest("ghost", r#"["ghost"]"#);
222 let err = compute_with(&ghost, None, RUSTC_TEST_VERSION).unwrap_err();
223 assert!(err.contains("ghost"), "names the unknown module: {err}");
224 let _ = std::fs::remove_file(ghost);
225 }
226}