Skip to main content

omena_query/
build_snapshot.rs

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}