1use omena_query_transform_runner::{
2 TargetTransformOptionsV0, all_transform_pass_kinds, plan_target_transforms_from_query,
3 plan_transform_passes,
4};
5use omena_resolver::omena_resolver_style_identity_generation;
6use omena_sif::{compute_omena_sif_leaf_hash_v1, write_omena_canonical_json_bytes_v1};
7use serde::{Deserialize, Serialize};
8use serde_json::Value;
9
10use crate::{OmenaQueryStylePackageManifestV0, OmenaQueryStyleSourceInputV0};
11
12const BUILD_SNAPSHOT_SCHEMA_VERSION_V0: &str = "0";
13const BUILD_SNAPSHOT_PRODUCT_V0: &str = "omena-query.build-snapshot-digest";
14
15#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub struct OmenaQueryBuildSnapshotContentInputV0 {
18 pub path: String,
19 pub source: String,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct OmenaQueryBuildSnapshotIdentityInputV0 {
25 pub target_path: String,
26 pub style_sources: Vec<OmenaQueryStyleSourceInputV0>,
27 #[serde(default)]
28 pub package_manifests: Vec<OmenaQueryStylePackageManifestV0>,
29 #[serde(default)]
30 pub config_inputs: Vec<OmenaQueryBuildSnapshotContentInputV0>,
31 #[serde(default)]
32 pub pass_ids: Vec<String>,
33 pub target_query: Option<String>,
34 #[serde(default)]
35 pub target_options: TargetTransformOptionsV0,
36 #[serde(default)]
37 pub adapter_environment: Value,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
41#[serde(rename_all = "camelCase")]
42pub struct OmenaQueryBuildSnapshotContentDigestV0 {
43 pub path: String,
44 pub content_digest: String,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
48#[serde(rename_all = "camelCase")]
49pub struct OmenaQueryBuildSnapshotIdentityV0 {
50 pub schema_version: &'static str,
51 pub product: &'static str,
52 pub content_hash_algorithm: &'static str,
53 pub digest: String,
54 pub target_source_digest: String,
55 pub style_source_digests: Vec<OmenaQueryBuildSnapshotContentDigestV0>,
56 pub package_manifest_digests: Vec<OmenaQueryBuildSnapshotContentDigestV0>,
57 pub config_digests: Vec<OmenaQueryBuildSnapshotContentDigestV0>,
58 pub resolver_generation: u64,
59 pub target_data_snapshot_id: String,
60 pub engine_abi_version: String,
61 pub pass_plan_digest: String,
62 pub requested_pass_ids: Vec<String>,
63 pub ordered_pass_ids: Vec<String>,
64 pub unknown_pass_ids: Vec<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(rename_all = "camelCase")]
69struct BuildSnapshotNativeIdentityV0 {
70 resolver_generation: u64,
71 target_data_snapshot_id: String,
72 engine_abi_version: String,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase")]
77struct BuildSnapshotDigestMaterialV0<'a> {
78 schema_version: &'static str,
79 product: &'static str,
80 target_path: &'a str,
81 target_source_digest: &'a str,
82 style_source_digests: &'a [OmenaQueryBuildSnapshotContentDigestV0],
83 package_manifest_digests: &'a [OmenaQueryBuildSnapshotContentDigestV0],
84 config_digests: &'a [OmenaQueryBuildSnapshotContentDigestV0],
85 native_identity: &'a BuildSnapshotNativeIdentityV0,
86 pass_plan_digest: &'a str,
87 requested_pass_ids: &'a [String],
88 ordered_pass_ids: &'a [String],
89 unknown_pass_ids: &'a [String],
90 target_query: &'a Option<String>,
91 target_options: TargetTransformOptionsV0,
92 adapter_environment: &'a Value,
93}
94
95pub fn compute_omena_query_build_snapshot_identity_v0(
96 input: &OmenaQueryBuildSnapshotIdentityInputV0,
97) -> Result<OmenaQueryBuildSnapshotIdentityV0, String> {
98 let target_plan = plan_target_transforms_from_query(
99 input.target_query.as_deref().unwrap_or("modern"),
100 input.target_options,
101 );
102 let native_identity = BuildSnapshotNativeIdentityV0 {
103 resolver_generation: omena_resolver_style_identity_generation(),
104 target_data_snapshot_id: target_plan.target_data_snapshot_id,
105 engine_abi_version: format!("omena-query@{}", env!("CARGO_PKG_VERSION")),
106 };
107 compute_build_snapshot_identity_with_native_facts(input, native_identity)
108}
109
110fn compute_build_snapshot_identity_with_native_facts(
111 input: &OmenaQueryBuildSnapshotIdentityInputV0,
112 native_identity: BuildSnapshotNativeIdentityV0,
113) -> Result<OmenaQueryBuildSnapshotIdentityV0, String> {
114 let style_source_digests = digest_style_sources(input.style_sources.as_slice())?;
115 let package_manifest_digests = digest_package_manifests(input.package_manifests.as_slice())?;
116 let config_digests = digest_content_inputs(input.config_inputs.as_slice(), "config")?;
117 let target_source_digest = style_source_digests
118 .iter()
119 .find(|entry| entry.path == input.target_path)
120 .map(|entry| entry.content_digest.clone())
121 .ok_or_else(|| {
122 format!(
123 "build snapshot target {} is absent from styleSources",
124 input.target_path
125 )
126 })?;
127
128 let available_passes = all_transform_pass_kinds();
129 let mut requested_passes = Vec::new();
130 let mut unknown_pass_ids = Vec::new();
131 for pass_id in &input.pass_ids {
132 match available_passes
133 .iter()
134 .copied()
135 .find(|candidate| candidate.id() == pass_id)
136 {
137 Some(pass) => requested_passes.push(pass),
138 None => unknown_pass_ids.push(pass_id.clone()),
139 }
140 }
141 let pass_plan = plan_transform_passes(requested_passes.as_slice());
142 let ordered_pass_ids = pass_plan
143 .ordered_pass_ids
144 .iter()
145 .map(|pass_id| (*pass_id).to_owned())
146 .collect::<Vec<_>>();
147 let pass_plan_digest = canonical_digest(&pass_plan)?;
148
149 let material = BuildSnapshotDigestMaterialV0 {
150 schema_version: BUILD_SNAPSHOT_SCHEMA_VERSION_V0,
151 product: BUILD_SNAPSHOT_PRODUCT_V0,
152 target_path: input.target_path.as_str(),
153 target_source_digest: target_source_digest.as_str(),
154 style_source_digests: style_source_digests.as_slice(),
155 package_manifest_digests: package_manifest_digests.as_slice(),
156 config_digests: config_digests.as_slice(),
157 native_identity: &native_identity,
158 pass_plan_digest: pass_plan_digest.as_str(),
159 requested_pass_ids: input.pass_ids.as_slice(),
160 ordered_pass_ids: ordered_pass_ids.as_slice(),
161 unknown_pass_ids: unknown_pass_ids.as_slice(),
162 target_query: &input.target_query,
163 target_options: input.target_options,
164 adapter_environment: &input.adapter_environment,
165 };
166 let digest = canonical_digest(&material)?;
167
168 Ok(OmenaQueryBuildSnapshotIdentityV0 {
169 schema_version: BUILD_SNAPSHOT_SCHEMA_VERSION_V0,
170 product: BUILD_SNAPSHOT_PRODUCT_V0,
171 content_hash_algorithm: "blake3",
172 digest,
173 target_source_digest,
174 style_source_digests,
175 package_manifest_digests,
176 config_digests,
177 resolver_generation: native_identity.resolver_generation,
178 target_data_snapshot_id: native_identity.target_data_snapshot_id,
179 engine_abi_version: native_identity.engine_abi_version,
180 pass_plan_digest,
181 requested_pass_ids: input.pass_ids.clone(),
182 ordered_pass_ids,
183 unknown_pass_ids,
184 })
185}
186
187fn digest_style_sources(
188 inputs: &[OmenaQueryStyleSourceInputV0],
189) -> Result<Vec<OmenaQueryBuildSnapshotContentDigestV0>, String> {
190 digest_path_sources(
191 inputs
192 .iter()
193 .map(|input| (input.style_path.as_str(), input.style_source.as_str())),
194 "style source",
195 )
196}
197
198fn digest_package_manifests(
199 inputs: &[OmenaQueryStylePackageManifestV0],
200) -> Result<Vec<OmenaQueryBuildSnapshotContentDigestV0>, String> {
201 digest_path_sources(
202 inputs.iter().map(|input| {
203 (
204 input.package_json_path.as_str(),
205 input.package_json_source.as_str(),
206 )
207 }),
208 "package manifest",
209 )
210}
211
212fn digest_content_inputs(
213 inputs: &[OmenaQueryBuildSnapshotContentInputV0],
214 kind: &str,
215) -> Result<Vec<OmenaQueryBuildSnapshotContentDigestV0>, String> {
216 digest_path_sources(
217 inputs
218 .iter()
219 .map(|input| (input.path.as_str(), input.source.as_str())),
220 kind,
221 )
222}
223
224fn digest_path_sources<'a>(
225 inputs: impl IntoIterator<Item = (&'a str, &'a str)>,
226 kind: &str,
227) -> Result<Vec<OmenaQueryBuildSnapshotContentDigestV0>, String> {
228 let mut digests = inputs
229 .into_iter()
230 .map(|(path, source)| OmenaQueryBuildSnapshotContentDigestV0 {
231 path: path.to_owned(),
232 content_digest: compute_omena_sif_leaf_hash_v1(source.as_bytes())
233 .as_str()
234 .to_owned(),
235 })
236 .collect::<Vec<_>>();
237 digests.sort_by(|left, right| left.path.cmp(&right.path));
238 for pair in digests.windows(2) {
239 if pair[0].path == pair[1].path {
240 return Err(format!("duplicate {kind} path: {}", pair[0].path));
241 }
242 }
243 Ok(digests)
244}
245
246fn canonical_digest(value: &impl Serialize) -> Result<String, String> {
247 let bytes = write_omena_canonical_json_bytes_v1(value)
248 .map_err(|error| format!("build snapshot canonicalization failed: {error}"))?;
249 Ok(compute_omena_sif_leaf_hash_v1(bytes.as_slice())
250 .as_str()
251 .to_owned())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257 use omena_resolver::invalidate_omena_resolver_style_identity_cache;
258
259 fn fixture_input() -> OmenaQueryBuildSnapshotIdentityInputV0 {
260 OmenaQueryBuildSnapshotIdentityInputV0 {
261 target_path: "/workspace/App.module.css".to_owned(),
262 style_sources: vec![
263 OmenaQueryStyleSourceInputV0 {
264 style_path: "/workspace/App.module.css".to_owned(),
265 style_source: "@import \"./tokens.module.css\";".to_owned(),
266 },
267 OmenaQueryStyleSourceInputV0 {
268 style_path: "/workspace/tokens.module.css".to_owned(),
269 style_source: ":root { --brand: red; }".to_owned(),
270 },
271 ],
272 package_manifests: vec![OmenaQueryStylePackageManifestV0 {
273 package_json_path: "/workspace/package.json".to_owned(),
274 package_json_source: "{\"exports\":{}}".to_owned(),
275 }],
276 config_inputs: vec![OmenaQueryBuildSnapshotContentInputV0 {
277 path: "/workspace/omena.config.json".to_owned(),
278 source: "{\"build\":{\"minify\":false}}".to_owned(),
279 }],
280 pass_ids: vec!["comment-strip".to_owned()],
281 target_query: Some("chrome 122".to_owned()),
282 target_options: TargetTransformOptionsV0::default(),
283 adapter_environment: serde_json::json!({"sourceMap": true}),
284 }
285 }
286
287 fn native_facts() -> BuildSnapshotNativeIdentityV0 {
288 BuildSnapshotNativeIdentityV0 {
289 resolver_generation: 7,
290 target_data_snapshot_id: "blake3:target-data".to_owned(),
291 engine_abi_version: "omena-query@0.4.0".to_owned(),
292 }
293 }
294
295 #[test]
296 fn every_content_family_changes_the_build_snapshot_digest() -> Result<(), String> {
297 let baseline = fixture_input();
298 let baseline_digest =
299 compute_build_snapshot_identity_with_native_facts(&baseline, native_facts())?.digest;
300
301 let mut source_changed = baseline.clone();
302 source_changed.style_sources[1].style_source = ":root { --brand: blue; }".to_owned();
303 let mut manifest_changed = baseline.clone();
304 manifest_changed.package_manifests[0].package_json_source =
305 "{\"exports\":{\".\":\"./blue.css\"}}".to_owned();
306 let mut config_changed = baseline.clone();
307 config_changed.config_inputs[0].source = "{\"build\":{\"minify\":true}}".to_owned();
308
309 for (family, changed) in [
310 ("style-source", source_changed),
311 ("package-manifest", manifest_changed),
312 ("config", config_changed),
313 ] {
314 let digest =
315 compute_build_snapshot_identity_with_native_facts(&changed, native_facts())?.digest;
316 assert_ne!(
317 digest, baseline_digest,
318 "{family} content must change the build snapshot digest"
319 );
320 }
321 Ok(())
322 }
323
324 #[test]
325 fn every_native_identity_family_changes_the_build_snapshot_digest() -> Result<(), String> {
326 let input = fixture_input();
327 let baseline = compute_build_snapshot_identity_with_native_facts(&input, native_facts())?;
328
329 let mut resolver_changed = native_facts();
330 resolver_changed.resolver_generation += 1;
331 let mut target_data_changed = native_facts();
332 target_data_changed.target_data_snapshot_id = "blake3:new-target-data".to_owned();
333 let mut abi_changed = native_facts();
334 abi_changed.engine_abi_version = "omena-query@next".to_owned();
335
336 for (family, facts) in [
337 ("resolver-generation", resolver_changed),
338 ("target-data-snapshot", target_data_changed),
339 ("engine-abi", abi_changed),
340 ] {
341 let changed = compute_build_snapshot_identity_with_native_facts(&input, facts)?;
342 assert_ne!(
343 changed.digest, baseline.digest,
344 "{family} must change the build snapshot digest"
345 );
346 }
347 Ok(())
348 }
349
350 #[test]
351 fn adapter_environment_and_target_options_change_the_build_snapshot_digest()
352 -> Result<(), String> {
353 let baseline = fixture_input();
354 let baseline_digest =
355 compute_build_snapshot_identity_with_native_facts(&baseline, native_facts())?.digest;
356
357 let mut environment_changed = baseline.clone();
358 environment_changed.adapter_environment = serde_json::json!({"sourceMap": false});
359 let environment_digest = compute_build_snapshot_identity_with_native_facts(
360 &environment_changed,
361 native_facts(),
362 )?
363 .digest;
364
365 let mut target_options_changed = baseline;
366 target_options_changed.target_options.allow_scope_flatten = true;
367 let target_options_digest = compute_build_snapshot_identity_with_native_facts(
368 &target_options_changed,
369 native_facts(),
370 )?
371 .digest;
372
373 assert_ne!(environment_digest, baseline_digest);
374 assert_ne!(target_options_digest, baseline_digest);
375 assert_ne!(target_options_digest, environment_digest);
376 Ok(())
377 }
378
379 #[test]
380 fn live_resolver_generation_changes_the_public_build_snapshot_digest() -> Result<(), String> {
381 let input = fixture_input();
382 let before = compute_omena_query_build_snapshot_identity_v0(&input)?;
383 invalidate_omena_resolver_style_identity_cache();
384 let after = compute_omena_query_build_snapshot_identity_v0(&input)?;
385
386 assert_eq!(after.resolver_generation, before.resolver_generation + 1);
387 assert_ne!(after.digest, before.digest);
388 Ok(())
389 }
390
391 #[test]
392 fn pass_plan_and_target_query_changes_are_bound() -> Result<(), String> {
393 let baseline = fixture_input();
394 let baseline_identity =
395 compute_build_snapshot_identity_with_native_facts(&baseline, native_facts())?;
396
397 let mut pass_changed = baseline.clone();
398 pass_changed.pass_ids.push("color-compression".to_owned());
399 let pass_identity =
400 compute_build_snapshot_identity_with_native_facts(&pass_changed, native_facts())?;
401 assert_ne!(
402 pass_identity.pass_plan_digest,
403 baseline_identity.pass_plan_digest
404 );
405 assert_ne!(pass_identity.digest, baseline_identity.digest);
406
407 let mut target_changed = baseline;
408 target_changed.target_query = Some("safari 16.2".to_owned());
409 let target_identity =
410 compute_build_snapshot_identity_with_native_facts(&target_changed, native_facts())?;
411 assert_ne!(target_identity.digest, baseline_identity.digest);
412 Ok(())
413 }
414
415 #[test]
416 fn duplicate_or_missing_style_paths_fail_closed() -> Result<(), String> {
417 let mut missing = fixture_input();
418 missing.style_sources.remove(0);
419 let missing_error =
420 match compute_build_snapshot_identity_with_native_facts(&missing, native_facts()) {
421 Err(error) => error,
422 Ok(_) => return Err("a missing target source must fail closed".to_owned()),
423 };
424 assert!(
425 missing_error.contains("absent from styleSources"),
426 "missing target source failure must identify the absent style source"
427 );
428
429 let mut duplicate = fixture_input();
430 duplicate
431 .style_sources
432 .push(duplicate.style_sources[0].clone());
433 let duplicate_error =
434 match compute_build_snapshot_identity_with_native_facts(&duplicate, native_facts()) {
435 Err(error) => error,
436 Ok(_) => return Err("a duplicate style source path must fail closed".to_owned()),
437 };
438 assert!(
439 duplicate_error.contains("duplicate style source path"),
440 "duplicate style source failure must identify the duplicated path"
441 );
442 Ok(())
443 }
444}