Skip to main content

omena_testkit/
fixture.rs

1use serde::Serialize;
2use std::collections::BTreeSet;
3
4/// One reusable fixture seed consumed by the testkit boundary report.
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct OmenaTestkitFixtureSeedV0 {
7    /// Stable fixture label.
8    pub label: &'static str,
9    /// Fixture lane such as `sass-language` or `cascade-proof`.
10    pub lane: &'static str,
11    /// Raw `cme-fixture-v0` text.
12    pub raw: &'static str,
13    /// Product surfaces expected to consume this fixture.
14    pub expected_products: &'static [&'static str],
15    /// Promotion target for M4.
16    pub promotion_target: &'static str,
17}
18
19/// Parsed reusable fixture.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct CmeFixtureV0 {
23    /// Fixture grammar version.
24    pub schema_version: &'static str,
25    /// Parsed files.
26    pub files: Vec<CmeFixtureFileV0>,
27    /// Parsed expectations.
28    pub expectations: Vec<CmeFixtureExpectationV0>,
29}
30
31/// One file section in a reusable fixture.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct CmeFixtureFileV0 {
35    /// Workspace-relative file path.
36    pub path: String,
37    /// File-header metadata such as dialect or layer.
38    pub metadata: Vec<CmeFixtureFileMetadataV0>,
39    /// Markers removed from the source while preserving clean-source offsets.
40    pub markers: Vec<CmeFixtureMarkerV0>,
41    /// File text.
42    pub source: String,
43}
44
45/// One metadata key/value pair from a fixture file header.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct CmeFixtureFileMetadataV0 {
49    /// Metadata key.
50    pub key: String,
51    /// Metadata value.
52    pub value: String,
53}
54
55/// One marker extracted from fixture source text.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct CmeFixtureMarkerV0 {
59    /// Marker kind such as `cursor`, `namedPoint`, or `rangeStart`.
60    pub kind: &'static str,
61    /// Optional marker payload.
62    pub name: Option<String>,
63    /// Byte offset in the cleaned source.
64    pub byte_start: usize,
65    /// Byte end in the cleaned source.
66    pub byte_end: usize,
67}
68
69/// One expectation section in a reusable fixture.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CmeFixtureExpectationV0 {
73    /// Expectation key.
74    pub key: String,
75    /// Expectation text.
76    pub value: String,
77}
78
79/// Known `cme-fixture-v0` expectation families.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
81#[serde(rename_all = "camelCase")]
82pub enum CmeFixtureExpectationKindV0 {
83    /// Product-surface expectation.
84    Product,
85    /// Free-form assertion text.
86    Assertion,
87    /// Expected diagnostic with code/range/message details in the body.
88    Diagnostic,
89    /// A diagnostic code or code pattern that must not appear.
90    NoDiagnostic,
91    /// Expected diagnostic count, usually `<code>:<n>`.
92    Count,
93    /// Expected cascade winner declaration id.
94    CascadeOutcome,
95    /// Expected declaration participating in a cascade witness.
96    CascadeWitness,
97    /// Expected external-reference boundary state.
98    BoundaryState,
99    /// Unknown or product-owned expectation family.
100    Unknown,
101}
102
103impl CmeFixtureExpectationV0 {
104    /// Classify the expectation family without interpreting product-owned
105    /// expectation payloads.
106    pub fn kind(&self) -> CmeFixtureExpectationKindV0 {
107        cme_fixture_expectation_kind_from_key(&self.key)
108    }
109}
110
111/// Classify a raw expectation key by its leading keyword.
112pub fn cme_fixture_expectation_kind_from_key(key: &str) -> CmeFixtureExpectationKindV0 {
113    match key.split_whitespace().next().unwrap_or_default() {
114        "product" => CmeFixtureExpectationKindV0::Product,
115        "assertion" => CmeFixtureExpectationKindV0::Assertion,
116        "diagnostic" => CmeFixtureExpectationKindV0::Diagnostic,
117        "no-diagnostic" => CmeFixtureExpectationKindV0::NoDiagnostic,
118        "count" => CmeFixtureExpectationKindV0::Count,
119        "cascade-outcome" => CmeFixtureExpectationKindV0::CascadeOutcome,
120        "cascade-witness" => CmeFixtureExpectationKindV0::CascadeWitness,
121        "boundary-state" => CmeFixtureExpectationKindV0::BoundaryState,
122        _ => CmeFixtureExpectationKindV0::Unknown,
123    }
124}
125
126/// Parsed fixture seed evidence.
127#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct OmenaTestkitFixtureSeedReportV0 {
130    /// Stable fixture label.
131    pub label: &'static str,
132    /// Fixture lane.
133    pub lane: &'static str,
134    /// Whether the fixture parses with `cme-fixture-v0`.
135    pub parses: bool,
136    /// Parse error when present.
137    pub parse_error: Option<String>,
138    /// Parsed file count.
139    pub file_count: usize,
140    /// Parsed expectation count.
141    pub expectation_count: usize,
142    /// Parsed file-header metadata count.
143    pub metadata_count: usize,
144    /// Parsed marker count.
145    pub marker_count: usize,
146    /// Expected product surfaces.
147    pub expected_products: Vec<&'static str>,
148    /// Promotion target for M4.
149    pub promotion_target: &'static str,
150}
151
152/// Fixture seed corpus summary.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "camelCase")]
155pub struct OmenaTestkitFixtureSeedCorpusReportV0 {
156    /// Schema version.
157    pub schema_version: &'static str,
158    /// Product surface name.
159    pub product: &'static str,
160    /// Fixture grammar.
161    pub fixture_grammar: &'static str,
162    /// Fixture count.
163    pub fixture_count: usize,
164    /// Covered lane count.
165    pub lane_count: usize,
166    /// Parsed metadata count across all seed files.
167    pub metadata_count: usize,
168    /// Parsed marker count across all seed files.
169    pub marker_count: usize,
170    /// Whether every seed parses with the shared fixture grammar.
171    pub all_seeds_parse: bool,
172    /// Seed reports.
173    pub reports: Vec<OmenaTestkitFixtureSeedReportV0>,
174}
175
176/// Summarize any `cme-fixture-v0` fixture seed corpus.
177pub fn summarize_omena_testkit_fixture_seed_corpus(
178    seeds: &[OmenaTestkitFixtureSeedV0],
179) -> OmenaTestkitFixtureSeedCorpusReportV0 {
180    let reports = seeds
181        .iter()
182        .copied()
183        .map(report_fixture_seed)
184        .collect::<Vec<_>>();
185    let all_seeds_parse = reports.iter().all(|report| report.parses);
186    let lane_count = reports
187        .iter()
188        .map(|report| report.lane)
189        .collect::<BTreeSet<_>>()
190        .len();
191    let metadata_count = reports.iter().map(|report| report.metadata_count).sum();
192    let marker_count = reports.iter().map(|report| report.marker_count).sum();
193
194    OmenaTestkitFixtureSeedCorpusReportV0 {
195        schema_version: "0",
196        product: "omena-testkit.fixture-seed-corpus",
197        fixture_grammar: "cme-fixture-v0",
198        fixture_count: reports.len(),
199        lane_count,
200        metadata_count,
201        marker_count,
202        all_seeds_parse,
203        reports,
204    }
205}
206
207/// Parse a `cme-fixture-v0` workspace fixture.
208pub fn parse_cme_fixture_v0(raw: &str) -> Result<CmeFixtureV0, String> {
209    let mut files = Vec::new();
210    let mut expectations = Vec::new();
211    let mut current_file: Option<CmeFixtureFileV0> = None;
212    let mut current_expectation: Option<CmeFixtureExpectationV0> = None;
213
214    for line in raw.lines() {
215        if let Some(header) = line
216            .strip_prefix("--- file:")
217            .or_else(|| line.strip_prefix("//-"))
218        {
219            finish_fixture_section(&mut files, current_file.take());
220            finish_fixture_section(&mut expectations, current_expectation.take());
221            let (path, metadata) = parse_cme_fixture_file_header(header.trim())?;
222            current_file = Some(CmeFixtureFileV0 {
223                path,
224                metadata,
225                markers: Vec::new(),
226                source: String::new(),
227            });
228            continue;
229        }
230
231        if let Some(key) = line.strip_prefix("--- expect:") {
232            finish_fixture_section(&mut files, current_file.take());
233            finish_fixture_section(&mut expectations, current_expectation.take());
234            current_expectation = Some(CmeFixtureExpectationV0 {
235                key: key.trim().to_string(),
236                value: String::new(),
237            });
238            continue;
239        }
240
241        if let Some(file) = current_file.as_mut() {
242            push_fixture_line(&mut file.source, line);
243        } else if let Some(expectation) = current_expectation.as_mut() {
244            push_fixture_line(&mut expectation.value, line);
245        } else if !line.trim().is_empty() {
246            return Err("fixture content must start with a file or expect marker".to_string());
247        }
248    }
249
250    finish_fixture_section(&mut files, current_file.take());
251    finish_fixture_section(&mut expectations, current_expectation.take());
252
253    for file in &mut files {
254        let (cleaned_source, markers) = extract_cme_fixture_markers(&file.source)?;
255        file.source = cleaned_source;
256        file.markers = markers;
257    }
258
259    if files.is_empty() {
260        return Err("fixture must contain at least one file section".to_string());
261    }
262    if expectations.is_empty() {
263        return Err("fixture must contain at least one expectation section".to_string());
264    }
265
266    Ok(CmeFixtureV0 {
267        schema_version: "0",
268        files,
269        expectations,
270    })
271}
272
273fn report_fixture_seed(seed: OmenaTestkitFixtureSeedV0) -> OmenaTestkitFixtureSeedReportV0 {
274    match parse_cme_fixture_v0(seed.raw) {
275        Ok(fixture) => {
276            let metadata_count = fixture.files.iter().map(|file| file.metadata.len()).sum();
277            let marker_count = fixture.files.iter().map(|file| file.markers.len()).sum();
278            OmenaTestkitFixtureSeedReportV0 {
279                label: seed.label,
280                lane: seed.lane,
281                parses: true,
282                parse_error: None,
283                file_count: fixture.files.len(),
284                expectation_count: fixture.expectations.len(),
285                metadata_count,
286                marker_count,
287                expected_products: seed.expected_products.to_vec(),
288                promotion_target: seed.promotion_target,
289            }
290        }
291        Err(error) => OmenaTestkitFixtureSeedReportV0 {
292            label: seed.label,
293            lane: seed.lane,
294            parses: false,
295            parse_error: Some(error),
296            file_count: 0,
297            expectation_count: 0,
298            metadata_count: 0,
299            marker_count: 0,
300            expected_products: seed.expected_products.to_vec(),
301            promotion_target: seed.promotion_target,
302        },
303    }
304}
305
306fn finish_fixture_section<T>(sections: &mut Vec<T>, current: Option<T>) {
307    if let Some(section) = current {
308        sections.push(section);
309    }
310}
311
312fn push_fixture_line(buffer: &mut String, line: &str) {
313    if !buffer.is_empty() {
314        buffer.push('\n');
315    }
316    buffer.push_str(line);
317}
318
319fn parse_cme_fixture_file_header(
320    header: &str,
321) -> Result<(String, Vec<CmeFixtureFileMetadataV0>), String> {
322    let mut parts = header.split_whitespace();
323    let path = parts
324        .next()
325        .ok_or_else(|| "fixture file header must include a path".to_string())?;
326    if path.contains(':') {
327        return Err("fixture file header path must precede metadata".to_string());
328    }
329
330    let mut metadata = Vec::new();
331    for part in parts {
332        let Some((key, value)) = part.split_once(':') else {
333            return Err(format!("fixture metadata `{part}` must use key:value"));
334        };
335        validate_cme_fixture_metadata(key, value)?;
336        metadata.push(CmeFixtureFileMetadataV0 {
337            key: key.to_string(),
338            value: value.to_string(),
339        });
340    }
341
342    Ok((path.to_string(), metadata))
343}
344
345fn validate_cme_fixture_metadata(key: &str, value: &str) -> Result<(), String> {
346    if value.is_empty() {
347        return Err(format!("fixture metadata `{key}` must have a value"));
348    }
349    match key {
350        "dialect" => match value {
351            "css" | "scss" | "less" => Ok(()),
352            _ => Err("fixture dialect metadata must be css, scss, or less".to_string()),
353        },
354        "layer" | "composes-from" | "consumer-of" => Ok(()),
355        _ => Err(format!("fixture metadata key `{key}` is not supported")),
356    }
357}
358
359fn extract_cme_fixture_markers(source: &str) -> Result<(String, Vec<CmeFixtureMarkerV0>), String> {
360    let mut cleaned = String::new();
361    let mut markers = Vec::new();
362    let mut cursor = 0;
363
364    while let Some(relative_start) = source[cursor..].find("/*") {
365        let start = cursor + relative_start;
366        cleaned.push_str(&source[cursor..start]);
367        let Some(relative_end) = source[start + 2..].find("*/") else {
368            return Err("fixture marker comment is unterminated".to_string());
369        };
370        let end = start + 2 + relative_end + 2;
371        let body = &source[start + 2..end - 2];
372        if let Some(marker) = parse_cme_fixture_marker(body, cleaned.len())? {
373            markers.push(marker);
374        } else {
375            cleaned.push_str(&source[start..end]);
376        }
377        cursor = end;
378    }
379
380    cleaned.push_str(&source[cursor..]);
381    Ok((cleaned, markers))
382}
383
384fn parse_cme_fixture_marker(
385    body: &str,
386    byte_offset: usize,
387) -> Result<Option<CmeFixtureMarkerV0>, String> {
388    if body == "|" {
389        return Ok(Some(cme_fixture_marker("cursor", None, byte_offset)));
390    }
391    if let Some(name) = body.strip_prefix("at:") {
392        return Ok(Some(cme_fixture_marker(
393            "namedPoint",
394            Some(validate_cme_fixture_marker_payload("at", name)?),
395            byte_offset,
396        )));
397    }
398    if let Some(name) = body
399        .strip_prefix("</")
400        .and_then(|name| name.strip_suffix('>'))
401    {
402        return Ok(Some(cme_fixture_marker(
403            "rangeEnd",
404            Some(validate_cme_fixture_marker_payload("range end", name)?),
405            byte_offset,
406        )));
407    }
408    if let Some(name) = body
409        .strip_prefix('<')
410        .and_then(|name| name.strip_suffix('>'))
411    {
412        return Ok(Some(cme_fixture_marker(
413            "rangeStart",
414            Some(validate_cme_fixture_marker_payload("range start", name)?),
415            byte_offset,
416        )));
417    }
418    if let Some(name) = body.strip_prefix("name:") {
419        return Ok(Some(cme_fixture_marker(
420            "nameAnchor",
421            Some(validate_cme_fixture_marker_payload("name", name)?),
422            byte_offset,
423        )));
424    }
425    if let Some(target) = body.strip_prefix("from:") {
426        return Ok(Some(cme_fixture_marker(
427            "linkEnd",
428            Some(validate_cme_fixture_marker_payload("from", target)?),
429            byte_offset,
430        )));
431    }
432    Ok(None)
433}
434
435fn cme_fixture_marker(
436    kind: &'static str,
437    name: Option<String>,
438    byte_offset: usize,
439) -> CmeFixtureMarkerV0 {
440    CmeFixtureMarkerV0 {
441        kind,
442        name,
443        byte_start: byte_offset,
444        byte_end: byte_offset,
445    }
446}
447
448fn validate_cme_fixture_marker_payload(kind: &str, value: &str) -> Result<String, String> {
449    if value.is_empty() {
450        return Err(format!("fixture marker `{kind}` must have a value"));
451    }
452    Ok(value.to_string())
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    const CROSS_LANGUAGE_FIXTURE: &str = r#"--- file: src/App.tsx
460import styles from "./Button.module.scss";
461styles.button;
462--- file: src/Button.module.scss
463.button { color: red; }
464--- expect: product
465omena-query.source-syntax-index
466--- expect: assertion
467shared fixture parser keeps source and style files in the same workspace fixture
468"#;
469
470    #[test]
471    fn parses_reusable_cme_fixture_v0_sections() -> Result<(), String> {
472        let fixture = parse_cme_fixture_v0(
473            r#"--- file: src/proof.css
474.a { color: red; }
475--- expect: product
476omena-transform-passes.cascade-proof-obligations
477--- expect: assertion
478proof obligations remain product-visible
479"#,
480        )?;
481
482        assert_eq!(fixture.schema_version, "0");
483        assert_eq!(fixture.files.len(), 1);
484        assert_eq!(fixture.files[0].path, "src/proof.css");
485        assert!(fixture.files[0].source.contains(".a"));
486        assert_eq!(fixture.expectations.len(), 2);
487        assert_eq!(fixture.expectations[0].key, "product");
488        assert_eq!(
489            fixture.expectations[0].value,
490            "omena-transform-passes.cascade-proof-obligations"
491        );
492
493        Ok(())
494    }
495
496    #[test]
497    fn keeps_source_and_style_files_in_one_workspace_fixture() -> Result<(), String> {
498        let fixture = parse_cme_fixture_v0(CROSS_LANGUAGE_FIXTURE)?;
499
500        assert_eq!(fixture.files.len(), 2);
501        assert_eq!(fixture.files[0].path, "src/App.tsx");
502        assert_eq!(fixture.files[1].path, "src/Button.module.scss");
503        assert!(
504            fixture
505                .expectations
506                .iter()
507                .any(|expectation| expectation.value == "omena-query.source-syntax-index")
508        );
509
510        Ok(())
511    }
512
513    #[test]
514    fn parses_cme_fixture_v0_metadata_and_markers() -> Result<(), String> {
515        let fixture = parse_cme_fixture_v0(
516            r#"//- src/Card.module.scss dialect:scss layer:style
517.card { color: /*|*/red; }
518.card/*at:selector*/ { color: blue; }
519.card { color: /*<colorRange>*/green/*</colorRange>*/; }
520.card { composes: item/*from:src/Base.module.scss#item*/; }
521--- expect: product
522omena-testkit.fixture-markers
523"#,
524        )?;
525
526        assert_eq!(fixture.files.len(), 1);
527        assert_eq!(fixture.files[0].path, "src/Card.module.scss");
528        assert_eq!(
529            fixture.files[0]
530                .metadata
531                .iter()
532                .map(|metadata| (metadata.key.as_str(), metadata.value.as_str()))
533                .collect::<Vec<_>>(),
534            vec![("dialect", "scss"), ("layer", "style")]
535        );
536        assert_eq!(
537            fixture.files[0]
538                .markers
539                .iter()
540                .map(|marker| (marker.kind, marker.name.as_deref()))
541                .collect::<Vec<_>>(),
542            vec![
543                ("cursor", None),
544                ("namedPoint", Some("selector")),
545                ("rangeStart", Some("colorRange")),
546                ("rangeEnd", Some("colorRange")),
547                ("linkEnd", Some("src/Base.module.scss#item"))
548            ]
549        );
550        assert!(fixture.files[0].source.contains(".card { color: red; }"));
551        assert!(!fixture.files[0].source.contains("/*|*/"));
552        assert_eq!(
553            fixture.files[0].markers[0].byte_start,
554            ".card { color: ".len()
555        );
556
557        Ok(())
558    }
559
560    #[test]
561    fn keeps_non_fixture_comments_in_source() -> Result<(), String> {
562        let fixture = parse_cme_fixture_v0(
563            r#"//- src/Card.module.css dialect:css
564.card { /* regular comment */ color: red; }
565--- expect: product
566omena-testkit.fixture-markers
567"#,
568        )?;
569
570        assert!(fixture.files[0].source.contains("/* regular comment */"));
571        assert!(fixture.files[0].markers.is_empty());
572
573        Ok(())
574    }
575
576    #[test]
577    fn classifies_m7_diagnostic_cascade_and_boundary_expectations() -> Result<(), String> {
578        let fixture = parse_cme_fixture_v0(
579            r#"//- src/Nested.module.scss dialect:scss
580.article {
581  &.box { &.fill { padding: 1px; } }
582}
583--- expect: diagnostic
584code: unreachableDeclaration
585range: colorRange
586--- expect: no-diagnostic unspecifiedCascadeTie
587--- expect: count unreachableDeclaration:0
588--- expect: cascade-outcome decl-1
589--- expect: cascade-witness decl-2
590--- expect: boundary-state ext-1 Resolved
591--- expect: boundary-state ext-2 Partial
592--- expect: boundary-state ext-3 Stale
593--- expect: boundary-state ext-4 Missing
594--- expect: boundary-state ext-5 Unresolved
595"#,
596        )?;
597
598        assert_eq!(
599            fixture
600                .expectations
601                .iter()
602                .map(CmeFixtureExpectationV0::kind)
603                .collect::<Vec<_>>(),
604            vec![
605                CmeFixtureExpectationKindV0::Diagnostic,
606                CmeFixtureExpectationKindV0::NoDiagnostic,
607                CmeFixtureExpectationKindV0::Count,
608                CmeFixtureExpectationKindV0::CascadeOutcome,
609                CmeFixtureExpectationKindV0::CascadeWitness,
610                CmeFixtureExpectationKindV0::BoundaryState,
611                CmeFixtureExpectationKindV0::BoundaryState,
612                CmeFixtureExpectationKindV0::BoundaryState,
613                CmeFixtureExpectationKindV0::BoundaryState,
614                CmeFixtureExpectationKindV0::BoundaryState,
615            ]
616        );
617        assert_eq!(
618            fixture.expectations[1].key,
619            "no-diagnostic unspecifiedCascadeTie"
620        );
621        assert_eq!(
622            fixture.expectations[2].key,
623            "count unreachableDeclaration:0"
624        );
625        Ok(())
626    }
627
628    #[test]
629    fn rejects_unknown_fixture_metadata() {
630        let error = parse_cme_fixture_v0(
631            r#"//- src/Card.module.css unknown:value
632.card { color: red; }
633--- expect: product
634omena-testkit.fixture-markers
635"#,
636        )
637        .err();
638
639        assert_eq!(
640            error.as_deref(),
641            Some("fixture metadata key `unknown` is not supported")
642        );
643    }
644
645    #[test]
646    fn rejects_fixture_without_sections() {
647        let error = parse_cme_fixture_v0("plain text").err();
648
649        assert_eq!(
650            error.as_deref(),
651            Some("fixture content must start with a file or expect marker")
652        );
653    }
654
655    #[test]
656    fn rejects_fixture_without_expectations() {
657        let error = parse_cme_fixture_v0(
658            r#"--- file: src/Button.module.scss
659.button { color: red; }
660"#,
661        )
662        .err();
663
664        assert_eq!(
665            error.as_deref(),
666            Some("fixture must contain at least one expectation section")
667        );
668    }
669
670    #[test]
671    fn summarizes_external_fixture_seed_corpus() {
672        let seeds = [OmenaTestkitFixtureSeedV0 {
673            label: "external",
674            lane: "consumer",
675            raw: r#"--- file: src/input.css
676.x { color: red; }
677--- expect: product
678consumer.product
679"#,
680            expected_products: &["consumer.product"],
681            promotion_target: "omena-testkit/consumer",
682        }];
683
684        let report = summarize_omena_testkit_fixture_seed_corpus(&seeds);
685
686        assert_eq!(report.product, "omena-testkit.fixture-seed-corpus");
687        assert_eq!(report.fixture_count, 1);
688        assert_eq!(report.lane_count, 1);
689        assert!(report.all_seeds_parse);
690        assert_eq!(report.reports[0].file_count, 1);
691        assert_eq!(report.reports[0].expectation_count, 1);
692    }
693}