Skip to main content

omena_transform_target/
lib.rs

1//! Target feature matrix planning for Omena CSS transforms.
2//!
3//! This crate owns the target-sensitive lowering decision boundary. It
4//! resolves standard browserslist queries through an embedded Can I Use snapshot,
5//! then folds the resolved browser set into the explicit Omena transform feature
6//! matrix. Named profiles stay available for product defaults and conservative
7//! non-browser environments.
8
9use std::{collections::BTreeSet, sync::OnceLock};
10
11use browserslist::{Distrib, Opts, resolve as resolve_browserslist};
12use omena_transform_cst::TransformPassKind;
13use omena_transform_passes::{TransformPassPlanV0, plan_transform_passes};
14use serde::{Deserialize, Serialize};
15
16const BROWSER_THRESHOLDS_SOURCE: &str = include_str!("../data/browser-thresholds.toml");
17const PASS_FEATURE_BINDINGS_SOURCE: &str = include_str!("../data/pass-feature-bindings.toml");
18
19#[derive(Debug, Clone, PartialEq, Eq, Default)]
20struct BrowserThresholdDataV0 {
21    schema_version: String,
22    product: String,
23    refreshed_at: String,
24    quorum_min_sources: usize,
25    thresholds: Vec<BrowserFeatureThresholdV0>,
26    parse_error_count: usize,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30struct BrowserFeatureThresholdV0 {
31    table: String,
32    browser: String,
33    min_major: u16,
34    min_minor: u16,
35    caniuse_key: String,
36    source_quorum: Vec<String>,
37    last_verified: String,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Default)]
41struct PassFeatureBindingDataV0 {
42    schema_version: String,
43    product: String,
44    refreshed_at: String,
45    bindings: Vec<PassFeatureBindingV0>,
46    parse_error_count: usize,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50struct PassFeatureBindingV0 {
51    pass_id: String,
52    caniuse_keys: Vec<String>,
53    support_table: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct TargetFeatureSupportV0 {
59    pub vendor_prefix_required: bool,
60    pub supports_light_dark: bool,
61    pub supports_color_mix: bool,
62    pub supports_oklch_oklab: bool,
63    pub supports_color_function: bool,
64    pub supports_logical_properties: bool,
65    pub supports_css_nesting: bool,
66    pub supports_css_scope: bool,
67    pub supports_cascade_layers: bool,
68}
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
71#[serde(rename_all = "camelCase")]
72#[serde(default)]
73pub struct TargetTransformOptionsV0 {
74    pub allow_logical_to_physical: bool,
75    pub allow_scope_flatten: bool,
76    pub allow_layer_flatten: bool,
77    pub enable_supports_static_eval: bool,
78    pub enable_media_static_eval: bool,
79    pub drop_dark_mode_media_queries: bool,
80}
81
82impl Default for TargetTransformOptionsV0 {
83    fn default() -> Self {
84        conservative_target_options()
85    }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct TransformTargetBoundarySummaryV0 {
91    pub schema_version: &'static str,
92    pub product: &'static str,
93    pub managed_pass_ids: Vec<&'static str>,
94    pub opt_in_pass_ids: Vec<&'static str>,
95    pub target_data_source: &'static str,
96    pub planner_surface: &'static str,
97    pub browser_threshold_table_count: usize,
98    pub browser_threshold_entry_count: usize,
99    pub pass_feature_binding_count: usize,
100    pub browser_data_source_files: Vec<&'static str>,
101    pub browser_data_parse_error_count: usize,
102    pub browser_data_quorum_valid: bool,
103    pub browser_data_bindings_valid: bool,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107#[serde(rename_all = "camelCase")]
108pub struct TransformTargetQueryPlanV0 {
109    pub schema_version: &'static str,
110    pub product: &'static str,
111    pub query: String,
112    pub normalized_query: String,
113    pub profile_id: &'static str,
114    pub recognized_profile: bool,
115    pub target_data_source: &'static str,
116    pub resolved_targets: Vec<String>,
117    pub resolution_error: Option<String>,
118    pub support: TargetFeatureSupportV0,
119    pub transform_plan: TransformTargetPlanV0,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
123#[serde(rename_all = "camelCase")]
124pub struct TransformTargetPlanV0 {
125    pub schema_version: &'static str,
126    pub product: &'static str,
127    pub target_label: String,
128    pub required_pass_ids: Vec<&'static str>,
129    pub blocked_pass_ids: Vec<&'static str>,
130    pub planned_pass_ids: Vec<&'static str>,
131    pub pass_plan: TransformPassPlanV0,
132}
133
134pub fn summarize_omena_transform_target_boundary() -> TransformTargetBoundarySummaryV0 {
135    let browser_data = browser_threshold_data();
136    let bindings = pass_feature_binding_data();
137
138    TransformTargetBoundarySummaryV0 {
139        schema_version: "0",
140        product: "omena-transform-target.boundary",
141        managed_pass_ids: target_managed_passes()
142            .iter()
143            .map(|pass| pass.id())
144            .collect(),
145        opt_in_pass_ids: vec![
146            TransformPassKind::LogicalToPhysical.id(),
147            TransformPassKind::ScopeFlatten.id(),
148            TransformPassKind::LayerFlatten.id(),
149        ],
150        target_data_source: "oxcBrowserslistV3+browserThresholdsTomlV0+staticTargetProfileV0+explicitFeatureMatrixV0",
151        planner_surface: "omena-transform-passes.plan",
152        browser_threshold_table_count: browser_threshold_table_count(browser_data),
153        browser_threshold_entry_count: browser_data.thresholds.len(),
154        pass_feature_binding_count: bindings.bindings.len(),
155        browser_data_source_files: vec![
156            "data/browser-thresholds.toml",
157            "data/pass-feature-bindings.toml",
158        ],
159        browser_data_parse_error_count: browser_data.parse_error_count + bindings.parse_error_count,
160        browser_data_quorum_valid: browser_threshold_data_is_valid(browser_data),
161        browser_data_bindings_valid: pass_feature_binding_data_is_valid(browser_data, bindings),
162    }
163}
164
165pub fn plan_target_transforms_from_query(
166    query: impl Into<String>,
167    options: TargetTransformOptionsV0,
168) -> TransformTargetQueryPlanV0 {
169    let query = query.into();
170    let normalized_query = normalize_target_query(&query);
171    let target_resolution = target_feature_support_for_query(&normalized_query);
172    let transform_plan = plan_target_transforms(
173        target_resolution.profile_id,
174        target_resolution.support,
175        options,
176    );
177
178    TransformTargetQueryPlanV0 {
179        schema_version: "0",
180        product: "omena-transform-target.query-plan",
181        query,
182        normalized_query,
183        profile_id: target_resolution.profile_id,
184        recognized_profile: target_resolution.recognized_profile,
185        target_data_source: target_resolution.target_data_source,
186        resolved_targets: target_resolution.resolved_targets,
187        resolution_error: target_resolution.resolution_error,
188        support: target_resolution.support,
189        transform_plan,
190    }
191}
192
193pub fn plan_target_transforms(
194    target_label: impl Into<String>,
195    support: TargetFeatureSupportV0,
196    options: TargetTransformOptionsV0,
197) -> TransformTargetPlanV0 {
198    let target_label = target_label.into();
199    let mut required_passes = Vec::new();
200    let mut blocked_passes = Vec::new();
201
202    if support.vendor_prefix_required {
203        required_passes.push(TransformPassKind::VendorPrefixing);
204    }
205    if !support.supports_light_dark {
206        required_passes.push(TransformPassKind::LightDarkLowering);
207    }
208    if !support.supports_color_mix {
209        required_passes.push(TransformPassKind::ColorMixLowering);
210    }
211    if !support.supports_oklch_oklab {
212        required_passes.push(TransformPassKind::OklchOklabLowering);
213    }
214    if !support.supports_color_function {
215        required_passes.push(TransformPassKind::ColorFunctionLowering);
216    }
217    if !support.supports_logical_properties {
218        push_required_or_blocked(
219            TransformPassKind::LogicalToPhysical,
220            options.allow_logical_to_physical,
221            &mut required_passes,
222            &mut blocked_passes,
223        );
224    }
225    if !support.supports_css_nesting {
226        required_passes.push(TransformPassKind::NestingUnwrap);
227    }
228    if !support.supports_css_scope {
229        push_required_or_blocked(
230            TransformPassKind::ScopeFlatten,
231            options.allow_scope_flatten,
232            &mut required_passes,
233            &mut blocked_passes,
234        );
235    }
236    if !support.supports_cascade_layers {
237        push_required_or_blocked(
238            TransformPassKind::LayerFlatten,
239            options.allow_layer_flatten,
240            &mut required_passes,
241            &mut blocked_passes,
242        );
243    }
244    if options.enable_supports_static_eval {
245        required_passes.push(TransformPassKind::SupportsStaticEval);
246    }
247    if options.enable_media_static_eval {
248        required_passes.push(TransformPassKind::MediaStaticEval);
249    }
250    if options.drop_dark_mode_media_queries {
251        required_passes.push(TransformPassKind::DeadMediaBranchRemoval);
252    }
253
254    required_passes.sort_by_key(|pass| pass.ordinal());
255    required_passes.dedup();
256    blocked_passes.sort_by_key(|pass| pass.ordinal());
257    blocked_passes.dedup();
258
259    let pass_plan = plan_transform_passes(&required_passes);
260    let planned_pass_ids = pass_plan.ordered_pass_ids.clone();
261
262    TransformTargetPlanV0 {
263        schema_version: "0",
264        product: "omena-transform-target.plan",
265        target_label,
266        required_pass_ids: required_passes.iter().map(|pass| pass.id()).collect(),
267        blocked_pass_ids: blocked_passes.iter().map(|pass| pass.id()).collect(),
268        planned_pass_ids,
269        pass_plan,
270    }
271}
272
273pub const fn modern_feature_support() -> TargetFeatureSupportV0 {
274    TargetFeatureSupportV0 {
275        vendor_prefix_required: false,
276        supports_light_dark: true,
277        supports_color_mix: true,
278        supports_oklch_oklab: true,
279        supports_color_function: true,
280        supports_logical_properties: true,
281        supports_css_nesting: true,
282        supports_css_scope: true,
283        supports_cascade_layers: true,
284    }
285}
286
287pub const fn legacy_webview_feature_support() -> TargetFeatureSupportV0 {
288    TargetFeatureSupportV0 {
289        vendor_prefix_required: true,
290        supports_light_dark: false,
291        supports_color_mix: false,
292        supports_oklch_oklab: false,
293        supports_color_function: false,
294        supports_logical_properties: false,
295        supports_css_nesting: false,
296        supports_css_scope: false,
297        supports_cascade_layers: false,
298    }
299}
300
301pub const fn conservative_target_options() -> TargetTransformOptionsV0 {
302    TargetTransformOptionsV0 {
303        allow_logical_to_physical: false,
304        allow_scope_flatten: false,
305        allow_layer_flatten: false,
306        enable_supports_static_eval: false,
307        enable_media_static_eval: false,
308        drop_dark_mode_media_queries: false,
309    }
310}
311
312struct TargetQueryResolutionV0 {
313    profile_id: &'static str,
314    recognized_profile: bool,
315    target_data_source: &'static str,
316    resolved_targets: Vec<String>,
317    resolution_error: Option<String>,
318    support: TargetFeatureSupportV0,
319}
320
321fn normalize_target_query(query: &str) -> String {
322    query
323        .split_whitespace()
324        .collect::<Vec<_>>()
325        .join(" ")
326        .trim()
327        .to_ascii_lowercase()
328}
329
330fn target_feature_support_for_query(normalized_query: &str) -> TargetQueryResolutionV0 {
331    if matches!(
332        normalized_query,
333        "" | "modern" | "baseline 2024" | "baseline-2024"
334    ) {
335        return TargetQueryResolutionV0 {
336            profile_id: "modern-evergreen",
337            recognized_profile: true,
338            target_data_source: "staticTargetProfileV0",
339            resolved_targets: Vec::new(),
340            resolution_error: None,
341            support: modern_feature_support(),
342        };
343    }
344
345    if normalized_query == "legacy" || normalized_query == "legacy-webview" {
346        return TargetQueryResolutionV0 {
347            profile_id: "legacy-webview",
348            recognized_profile: true,
349            target_data_source: "staticTargetProfileV0",
350            resolved_targets: Vec::new(),
351            resolution_error: None,
352            support: legacy_webview_feature_support(),
353        };
354    }
355
356    match resolve_browserslist(&[normalized_query], &Opts::default()) {
357        Ok(distribs) if !distribs.is_empty() => {
358            let resolved_targets = distribs.iter().map(distrib_key).collect::<Vec<_>>();
359            TargetQueryResolutionV0 {
360                profile_id: "browserslist-resolved",
361                recognized_profile: true,
362                target_data_source: "oxcBrowserslistV3+browserThresholdsTomlV0+featureSubsetV0",
363                support: feature_support_for_resolved_targets(&distribs),
364                resolved_targets,
365                resolution_error: None,
366            }
367        }
368        Ok(_) => unknown_conservative_resolution(None),
369        Err(error) => unknown_conservative_resolution(Some(error.to_string())),
370    }
371}
372
373fn unknown_conservative_resolution(resolution_error: Option<String>) -> TargetQueryResolutionV0 {
374    TargetQueryResolutionV0 {
375        profile_id: "unknown-conservative",
376        recognized_profile: false,
377        target_data_source: "staticTargetProfileV0",
378        resolved_targets: Vec::new(),
379        resolution_error,
380        support: legacy_webview_feature_support(),
381    }
382}
383
384fn feature_support_for_resolved_targets(distribs: &[Distrib]) -> TargetFeatureSupportV0 {
385    let flexbox_fully_supported =
386        target_set_is_subset_of_fully_supported_feature(distribs, "flexbox");
387    let sticky_fully_supported =
388        target_set_is_subset_of_fully_supported_feature(distribs, "css-sticky");
389
390    TargetFeatureSupportV0 {
391        vendor_prefix_required: !(flexbox_fully_supported && sticky_fully_supported),
392        supports_light_dark: target_set_is_subset_of_browser_threshold_table(
393            distribs,
394            "light_dark",
395        ),
396        supports_color_mix: target_set_is_subset_of_browser_threshold_table(distribs, "color_mix"),
397        supports_oklch_oklab: target_set_is_subset_of_fully_supported_feature(
398            distribs,
399            "css-lch-lab",
400        ),
401        supports_color_function: target_set_is_subset_of_fully_supported_feature(
402            distribs,
403            "css-color-function",
404        ),
405        supports_logical_properties: target_set_is_subset_of_fully_supported_feature(
406            distribs,
407            "css-logical-props",
408        ),
409        supports_css_nesting: target_set_is_subset_of_fully_supported_feature(
410            distribs,
411            "css-nesting",
412        ),
413        supports_css_scope: target_set_is_subset_of_fully_supported_feature(
414            distribs,
415            "css-cascade-scope",
416        ),
417        supports_cascade_layers: target_set_is_subset_of_fully_supported_feature(
418            distribs,
419            "css-cascade-layers",
420        ),
421    }
422}
423
424fn target_set_is_subset_of_browser_threshold_table(distribs: &[Distrib], table: &str) -> bool {
425    !distribs.is_empty()
426        && distribs
427            .iter()
428            .all(|distrib| distrib_matches_browser_threshold_table(distrib, table))
429}
430
431fn distrib_matches_browser_threshold_table(distrib: &Distrib, table: &str) -> bool {
432    browser_threshold_data()
433        .thresholds
434        .iter()
435        .filter(|threshold| threshold.table == table)
436        .find(|threshold| threshold.browser == distrib.name())
437        .is_some_and(|threshold| {
438            browser_version_at_least(distrib.version(), threshold.min_major, threshold.min_minor)
439        })
440}
441
442fn browser_threshold_data() -> &'static BrowserThresholdDataV0 {
443    static DATA: OnceLock<BrowserThresholdDataV0> = OnceLock::new();
444    DATA.get_or_init(|| {
445        parse_browser_threshold_data(BROWSER_THRESHOLDS_SOURCE).unwrap_or_else(|_| {
446            BrowserThresholdDataV0 {
447                parse_error_count: 1,
448                ..BrowserThresholdDataV0::default()
449            }
450        })
451    })
452}
453
454fn pass_feature_binding_data() -> &'static PassFeatureBindingDataV0 {
455    static DATA: OnceLock<PassFeatureBindingDataV0> = OnceLock::new();
456    DATA.get_or_init(|| {
457        parse_pass_feature_binding_data(PASS_FEATURE_BINDINGS_SOURCE).unwrap_or_else(|_| {
458            PassFeatureBindingDataV0 {
459                parse_error_count: 1,
460                ..PassFeatureBindingDataV0::default()
461            }
462        })
463    })
464}
465
466fn parse_browser_threshold_data(source: &str) -> Result<BrowserThresholdDataV0, String> {
467    let mut data = BrowserThresholdDataV0::default();
468    let mut current_threshold: Option<BrowserFeatureThresholdV0> = None;
469
470    for line in significant_toml_lines(source) {
471        if line == "[[threshold]]" {
472            if let Some(threshold) = current_threshold.take() {
473                data.thresholds.push(threshold);
474            }
475            current_threshold = Some(BrowserFeatureThresholdV0::default());
476            continue;
477        }
478
479        let (key, value) = parse_toml_assignment(&line)?;
480        if let Some(threshold) = current_threshold.as_mut() {
481            match key {
482                "table" => threshold.table = parse_toml_string(value)?,
483                "browser" => threshold.browser = parse_toml_string(value)?,
484                "min_major" => threshold.min_major = parse_toml_u16(value)?,
485                "min_minor" => threshold.min_minor = parse_toml_u16(value)?,
486                "caniuse_key" => threshold.caniuse_key = parse_toml_string(value)?,
487                "source_quorum" => threshold.source_quorum = parse_toml_string_array(value)?,
488                "last_verified" => threshold.last_verified = parse_toml_string(value)?,
489                _ => return Err(format!("unknown browser threshold key `{key}`")),
490            }
491        } else {
492            match key {
493                "schema_version" => data.schema_version = parse_toml_string(value)?,
494                "product" => data.product = parse_toml_string(value)?,
495                "refreshed_at" => data.refreshed_at = parse_toml_string(value)?,
496                "quorum_min_sources" => data.quorum_min_sources = parse_toml_usize(value)?,
497                _ => return Err(format!("unknown browser threshold root key `{key}`")),
498            }
499        }
500    }
501
502    if let Some(threshold) = current_threshold.take() {
503        data.thresholds.push(threshold);
504    }
505
506    Ok(data)
507}
508
509fn parse_pass_feature_binding_data(source: &str) -> Result<PassFeatureBindingDataV0, String> {
510    let mut data = PassFeatureBindingDataV0::default();
511    let mut current_binding: Option<PassFeatureBindingV0> = None;
512
513    for line in significant_toml_lines(source) {
514        if line == "[[binding]]" {
515            if let Some(binding) = current_binding.take() {
516                data.bindings.push(binding);
517            }
518            current_binding = Some(PassFeatureBindingV0::default());
519            continue;
520        }
521
522        let (key, value) = parse_toml_assignment(&line)?;
523        if let Some(binding) = current_binding.as_mut() {
524            match key {
525                "pass_id" => binding.pass_id = parse_toml_string(value)?,
526                "caniuse_keys" => binding.caniuse_keys = parse_toml_string_array(value)?,
527                "support_table" => binding.support_table = parse_toml_string(value)?,
528                _ => return Err(format!("unknown pass feature binding key `{key}`")),
529            }
530        } else {
531            match key {
532                "schema_version" => data.schema_version = parse_toml_string(value)?,
533                "product" => data.product = parse_toml_string(value)?,
534                "refreshed_at" => data.refreshed_at = parse_toml_string(value)?,
535                _ => return Err(format!("unknown pass feature binding root key `{key}`")),
536            }
537        }
538    }
539
540    if let Some(binding) = current_binding.take() {
541        data.bindings.push(binding);
542    }
543
544    Ok(data)
545}
546
547fn significant_toml_lines(source: &str) -> impl Iterator<Item = String> + '_ {
548    source
549        .lines()
550        .map(|line| line.split('#').next().unwrap_or("").trim())
551        .filter(|line| !line.is_empty())
552        .map(ToOwned::to_owned)
553}
554
555fn parse_toml_assignment(line: &str) -> Result<(&str, &str), String> {
556    let Some((key, value)) = line.split_once('=') else {
557        return Err(format!("invalid assignment `{line}`"));
558    };
559    Ok((key.trim(), value.trim()))
560}
561
562fn parse_toml_string(value: &str) -> Result<String, String> {
563    let value = value.trim();
564    if value.len() < 2 || !value.starts_with('"') || !value.ends_with('"') {
565        return Err(format!("expected quoted string, got `{value}`"));
566    }
567    Ok(value[1..value.len() - 1].to_string())
568}
569
570fn parse_toml_u16(value: &str) -> Result<u16, String> {
571    value
572        .trim()
573        .parse::<u16>()
574        .map_err(|error| format!("invalid u16 `{value}`: {error}"))
575}
576
577fn parse_toml_usize(value: &str) -> Result<usize, String> {
578    value
579        .trim()
580        .parse::<usize>()
581        .map_err(|error| format!("invalid usize `{value}`: {error}"))
582}
583
584fn parse_toml_string_array(value: &str) -> Result<Vec<String>, String> {
585    let value = value.trim();
586    if value.len() < 2 || !value.starts_with('[') || !value.ends_with(']') {
587        return Err(format!("expected string array, got `{value}`"));
588    }
589    let body = value[1..value.len() - 1].trim();
590    if body.is_empty() {
591        return Ok(Vec::new());
592    }
593
594    body.split(',')
595        .map(|item| parse_toml_string(item.trim()))
596        .collect()
597}
598
599fn browser_threshold_data_is_valid(data: &BrowserThresholdDataV0) -> bool {
600    data.parse_error_count == 0
601        && data.schema_version == "0"
602        && data.product == "omena-transform-target.browser-thresholds"
603        && is_iso_date(&data.refreshed_at)
604        && data.quorum_min_sources >= 2
605        && browser_threshold_table_count(data) >= 2
606        && data.thresholds.iter().all(|threshold| {
607            !threshold.table.is_empty()
608                && !threshold.browser.is_empty()
609                && !threshold.caniuse_key.is_empty()
610                && threshold.source_quorum.len() >= data.quorum_min_sources
611                && threshold
612                    .source_quorum
613                    .iter()
614                    .all(|source| matches!(source.as_str(), "caniuse" | "web-features" | "mdn-bcd"))
615                && is_iso_date(&threshold.last_verified)
616        })
617}
618
619fn pass_feature_binding_data_is_valid(
620    browser_data: &BrowserThresholdDataV0,
621    binding_data: &PassFeatureBindingDataV0,
622) -> bool {
623    binding_data.parse_error_count == 0
624        && binding_data.schema_version == "0"
625        && binding_data.product == "omena-transform-target.pass-feature-bindings"
626        && is_iso_date(&binding_data.refreshed_at)
627        && !binding_data.bindings.is_empty()
628        && binding_data.bindings.iter().all(|binding| {
629            !binding.pass_id.is_empty()
630                && target_managed_passes()
631                    .iter()
632                    .any(|pass| pass.id() == binding.pass_id)
633                && !binding.caniuse_keys.is_empty()
634                && binding.caniuse_keys.iter().all(|key| {
635                    browser_data.thresholds.iter().any(|threshold| {
636                        threshold.table == binding.support_table && threshold.caniuse_key == *key
637                    })
638                })
639        })
640}
641
642fn browser_threshold_table_count(data: &BrowserThresholdDataV0) -> usize {
643    data.thresholds
644        .iter()
645        .map(|threshold| threshold.table.as_str())
646        .collect::<BTreeSet<_>>()
647        .len()
648}
649
650fn is_iso_date(value: &str) -> bool {
651    let bytes = value.as_bytes();
652    bytes.len() == 10
653        && bytes[4] == b'-'
654        && bytes[7] == b'-'
655        && bytes
656            .iter()
657            .enumerate()
658            .all(|(index, byte)| matches!(index, 4 | 7) || byte.is_ascii_digit())
659}
660
661fn browser_version_at_least(version: &str, min_major: u16, min_minor: u16) -> bool {
662    if version.eq_ignore_ascii_case("tp") {
663        return true;
664    }
665
666    let version = version.split('-').next().unwrap_or(version);
667    let mut parts = version.split('.');
668    let Some(major) = parts.next().and_then(|part| part.parse::<u16>().ok()) else {
669        return false;
670    };
671    let minor = parts
672        .next()
673        .and_then(|part| part.parse::<u16>().ok())
674        .unwrap_or(0);
675
676    major > min_major || (major == min_major && minor >= min_minor)
677}
678
679fn target_set_is_subset_of_fully_supported_feature(distribs: &[Distrib], feature: &str) -> bool {
680    let query = format!("fully supports {feature}");
681    let Ok(feature_distribs) = resolve_browserslist(&[query.as_str()], &Opts::default()) else {
682        return false;
683    };
684    if feature_distribs.is_empty() {
685        return false;
686    }
687
688    let feature_targets = feature_distribs
689        .iter()
690        .map(distrib_key)
691        .collect::<BTreeSet<_>>();
692    distribs
693        .iter()
694        .all(|distrib| feature_targets.contains(&distrib_key(distrib)))
695}
696
697fn distrib_key(distrib: &Distrib) -> String {
698    distrib.to_string()
699}
700
701fn push_required_or_blocked(
702    pass: TransformPassKind,
703    allowed: bool,
704    required_passes: &mut Vec<TransformPassKind>,
705    blocked_passes: &mut Vec<TransformPassKind>,
706) {
707    if allowed {
708        required_passes.push(pass);
709    } else {
710        blocked_passes.push(pass);
711    }
712}
713
714fn target_managed_passes() -> [TransformPassKind; 12] {
715    [
716        TransformPassKind::VendorPrefixing,
717        TransformPassKind::LightDarkLowering,
718        TransformPassKind::ColorMixLowering,
719        TransformPassKind::OklchOklabLowering,
720        TransformPassKind::ColorFunctionLowering,
721        TransformPassKind::LogicalToPhysical,
722        TransformPassKind::NestingUnwrap,
723        TransformPassKind::ScopeFlatten,
724        TransformPassKind::LayerFlatten,
725        TransformPassKind::SupportsStaticEval,
726        TransformPassKind::MediaStaticEval,
727        TransformPassKind::DeadMediaBranchRemoval,
728    ]
729}
730
731#[cfg(test)]
732mod tests {
733    use super::{
734        TargetFeatureSupportV0, TargetTransformOptionsV0, conservative_target_options,
735        plan_target_transforms, plan_target_transforms_from_query,
736        summarize_omena_transform_target_boundary,
737    };
738
739    #[test]
740    fn exposes_target_lowering_boundary() {
741        let boundary = summarize_omena_transform_target_boundary();
742
743        assert_eq!(boundary.product, "omena-transform-target.boundary");
744        assert_eq!(boundary.managed_pass_ids.len(), 12);
745        assert_eq!(
746            boundary.target_data_source,
747            "oxcBrowserslistV3+browserThresholdsTomlV0+staticTargetProfileV0+explicitFeatureMatrixV0"
748        );
749        assert_eq!(boundary.browser_threshold_table_count, 2);
750        assert_eq!(boundary.browser_threshold_entry_count, 22);
751        assert_eq!(boundary.pass_feature_binding_count, 2);
752        assert_eq!(boundary.browser_data_parse_error_count, 0);
753        assert!(boundary.browser_data_quorum_valid);
754        assert!(boundary.browser_data_bindings_valid);
755        assert!(boundary.managed_pass_ids.contains(&"vendor-prefixing"));
756        assert!(boundary.managed_pass_ids.contains(&"media-static-eval"));
757        assert!(boundary.opt_in_pass_ids.contains(&"scope-flatten"));
758    }
759
760    #[test]
761    fn browser_data_governance_externalizes_thresholds_and_bindings() {
762        let boundary = summarize_omena_transform_target_boundary();
763
764        assert!(
765            boundary
766                .browser_data_source_files
767                .contains(&"data/browser-thresholds.toml")
768        );
769        assert!(
770            boundary
771                .browser_data_source_files
772                .contains(&"data/pass-feature-bindings.toml")
773        );
774        assert!(boundary.browser_data_quorum_valid);
775        assert!(boundary.browser_data_bindings_valid);
776    }
777
778    #[test]
779    fn plans_target_lowering_with_vendor_prefix_after_lowering_edges() {
780        let support = TargetFeatureSupportV0 {
781            vendor_prefix_required: true,
782            supports_light_dark: false,
783            supports_color_mix: false,
784            supports_oklch_oklab: true,
785            supports_color_function: true,
786            supports_logical_properties: true,
787            supports_css_nesting: false,
788            supports_css_scope: false,
789            supports_cascade_layers: false,
790        };
791        let options = TargetTransformOptionsV0 {
792            allow_logical_to_physical: false,
793            allow_scope_flatten: true,
794            allow_layer_flatten: true,
795            enable_supports_static_eval: true,
796            enable_media_static_eval: true,
797            drop_dark_mode_media_queries: false,
798        };
799
800        let plan = plan_target_transforms("legacy-webview", support, options);
801
802        assert_eq!(plan.pass_plan.violated_dag_edge_count, 0);
803        assert!(plan.required_pass_ids.contains(&"light-dark-lowering"));
804        assert!(plan.required_pass_ids.contains(&"color-mix-lowering"));
805        assert!(plan.required_pass_ids.contains(&"vendor-prefixing"));
806        assert!(plan.required_pass_ids.contains(&"scope-flatten"));
807        assert!(plan.required_pass_ids.contains(&"layer-flatten"));
808        let vendor_index = plan
809            .planned_pass_ids
810            .iter()
811            .position(|id| *id == "vendor-prefixing");
812        let light_dark_index = plan
813            .planned_pass_ids
814            .iter()
815            .position(|id| *id == "light-dark-lowering");
816        assert!(light_dark_index < vendor_index);
817    }
818
819    #[test]
820    fn blocks_opt_in_flattening_when_not_explicitly_enabled() {
821        let support = TargetFeatureSupportV0 {
822            supports_css_scope: false,
823            supports_cascade_layers: false,
824            ..super::modern_feature_support()
825        };
826
827        let plan = plan_target_transforms(
828            "modern-without-scope",
829            support,
830            conservative_target_options(),
831        );
832
833        assert!(plan.blocked_pass_ids.contains(&"scope-flatten"));
834        assert!(plan.blocked_pass_ids.contains(&"layer-flatten"));
835        assert!(!plan.required_pass_ids.contains(&"scope-flatten"));
836        assert!(!plan.required_pass_ids.contains(&"layer-flatten"));
837    }
838
839    #[test]
840    fn plans_dark_mode_media_drop_as_dead_media_branch_pass() {
841        let mut options = conservative_target_options();
842        options.drop_dark_mode_media_queries = true;
843
844        let plan = plan_target_transforms("modern", super::modern_feature_support(), options);
845
846        assert!(
847            plan.required_pass_ids
848                .contains(&"dead-media-branch-removal")
849        );
850        assert!(plan.planned_pass_ids.contains(&"dead-media-branch-removal"));
851    }
852
853    #[test]
854    fn plans_target_lowering_from_static_target_query_profiles() {
855        let options = TargetTransformOptionsV0 {
856            allow_logical_to_physical: true,
857            allow_scope_flatten: true,
858            allow_layer_flatten: true,
859            enable_supports_static_eval: true,
860            enable_media_static_eval: true,
861            drop_dark_mode_media_queries: false,
862        };
863        let plan = plan_target_transforms_from_query("legacy-webview", options);
864
865        assert!(plan.recognized_profile);
866        assert_eq!(plan.normalized_query, "legacy-webview");
867        assert_eq!(plan.profile_id, "legacy-webview");
868        assert!(plan.support.vendor_prefix_required);
869        assert_eq!(plan.transform_plan.pass_plan.violated_dag_edge_count, 0);
870        assert!(
871            plan.transform_plan
872                .required_pass_ids
873                .contains(&"vendor-prefixing")
874        );
875        assert!(
876            plan.transform_plan
877                .required_pass_ids
878                .contains(&"nesting-unwrap")
879        );
880        assert!(
881            plan.transform_plan
882                .required_pass_ids
883                .contains(&"logical-to-physical")
884        );
885
886        let modern = plan_target_transforms_from_query("modern", conservative_target_options());
887        assert!(modern.recognized_profile);
888        assert_eq!(modern.profile_id, "modern-evergreen");
889        assert_eq!(modern.target_data_source, "staticTargetProfileV0");
890        assert!(modern.transform_plan.required_pass_ids.is_empty());
891    }
892
893    #[test]
894    fn plans_target_lowering_from_resolved_browserslist_query() {
895        let options = TargetTransformOptionsV0 {
896            allow_logical_to_physical: true,
897            allow_scope_flatten: true,
898            allow_layer_flatten: true,
899            enable_supports_static_eval: false,
900            enable_media_static_eval: false,
901            drop_dark_mode_media_queries: false,
902        };
903        let plan = plan_target_transforms_from_query("ie 11", options);
904
905        assert!(plan.recognized_profile);
906        assert_eq!(plan.profile_id, "browserslist-resolved");
907        assert_eq!(
908            plan.target_data_source,
909            "oxcBrowserslistV3+browserThresholdsTomlV0+featureSubsetV0"
910        );
911        assert_eq!(plan.resolved_targets, vec!["ie 11"]);
912        assert_eq!(plan.resolution_error, None);
913        assert!(plan.support.vendor_prefix_required);
914        assert!(
915            plan.transform_plan
916                .required_pass_ids
917                .contains(&"vendor-prefixing")
918        );
919        assert!(
920            plan.transform_plan
921                .required_pass_ids
922                .contains(&"nesting-unwrap")
923        );
924        assert!(
925            plan.transform_plan
926                .required_pass_ids
927                .contains(&"logical-to-physical")
928        );
929    }
930
931    #[test]
932    fn resolves_light_dark_and_color_mix_from_static_compatibility_matrix() {
933        let chrome_122 =
934            plan_target_transforms_from_query("chrome 122", conservative_target_options());
935        assert_eq!(chrome_122.profile_id, "browserslist-resolved");
936        assert!(!chrome_122.support.supports_light_dark);
937        assert!(chrome_122.support.supports_color_mix);
938        assert!(
939            chrome_122
940                .transform_plan
941                .required_pass_ids
942                .contains(&"light-dark-lowering")
943        );
944        assert!(
945            !chrome_122
946                .transform_plan
947                .required_pass_ids
948                .contains(&"color-mix-lowering")
949        );
950
951        let chrome_123 =
952            plan_target_transforms_from_query("chrome 123", conservative_target_options());
953        assert!(chrome_123.support.supports_light_dark);
954        assert!(chrome_123.support.supports_color_mix);
955        assert!(
956            !chrome_123
957                .transform_plan
958                .required_pass_ids
959                .contains(&"light-dark-lowering")
960        );
961        assert!(
962            !chrome_123
963                .transform_plan
964                .required_pass_ids
965                .contains(&"color-mix-lowering")
966        );
967
968        let safari_16_2 =
969            plan_target_transforms_from_query("safari 16.2", conservative_target_options());
970        assert!(!safari_16_2.support.supports_light_dark);
971        assert!(safari_16_2.support.supports_color_mix);
972
973        let safari_17_5 =
974            plan_target_transforms_from_query("safari 17.5", conservative_target_options());
975        assert!(safari_17_5.support.supports_light_dark);
976        assert!(safari_17_5.support.supports_color_mix);
977    }
978
979    #[test]
980    fn invalid_target_query_uses_conservative_profile_without_claiming_recognition() {
981        let plan = plan_target_transforms_from_query("yuru 1.0", conservative_target_options());
982
983        assert!(!plan.recognized_profile);
984        assert_eq!(plan.profile_id, "unknown-conservative");
985        assert!(plan.resolution_error.is_some());
986        assert!(plan.support.vendor_prefix_required);
987        assert!(
988            plan.transform_plan
989                .required_pass_ids
990                .contains(&"vendor-prefixing")
991        );
992        assert!(
993            plan.transform_plan
994                .blocked_pass_ids
995                .contains(&"scope-flatten")
996        );
997    }
998}