1use serde::Serialize;
2use std::collections::BTreeSet;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub struct OmenaTestkitFixtureSeedV0 {
7 pub label: &'static str,
9 pub lane: &'static str,
11 pub raw: &'static str,
13 pub expected_products: &'static [&'static str],
15 pub promotion_target: &'static str,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "camelCase")]
22pub struct CmeFixtureV0 {
23 pub schema_version: &'static str,
25 pub files: Vec<CmeFixtureFileV0>,
27 pub expectations: Vec<CmeFixtureExpectationV0>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct CmeFixtureFileV0 {
35 pub path: String,
37 pub metadata: Vec<CmeFixtureFileMetadataV0>,
39 pub markers: Vec<CmeFixtureMarkerV0>,
41 pub source: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct CmeFixtureFileMetadataV0 {
49 pub key: String,
51 pub value: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct CmeFixtureMarkerV0 {
59 pub kind: &'static str,
61 pub name: Option<String>,
63 pub byte_start: usize,
65 pub byte_end: usize,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71#[serde(rename_all = "camelCase")]
72pub struct CmeFixtureExpectationV0 {
73 pub key: String,
75 pub value: String,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
81#[serde(rename_all = "camelCase")]
82pub enum CmeFixtureExpectationKindV0 {
83 Product,
85 Assertion,
87 Diagnostic,
89 NoDiagnostic,
91 Count,
93 CascadeOutcome,
95 CascadeWitness,
97 BoundaryState,
99 Unknown,
101}
102
103impl CmeFixtureExpectationV0 {
104 pub fn kind(&self) -> CmeFixtureExpectationKindV0 {
107 cme_fixture_expectation_kind_from_key(&self.key)
108 }
109}
110
111pub 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct OmenaTestkitFixtureSeedReportV0 {
130 pub label: &'static str,
132 pub lane: &'static str,
134 pub parses: bool,
136 pub parse_error: Option<String>,
138 pub file_count: usize,
140 pub expectation_count: usize,
142 pub metadata_count: usize,
144 pub marker_count: usize,
146 pub expected_products: Vec<&'static str>,
148 pub promotion_target: &'static str,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
154#[serde(rename_all = "camelCase")]
155pub struct OmenaTestkitFixtureSeedCorpusReportV0 {
156 pub schema_version: &'static str,
158 pub product: &'static str,
160 pub fixture_grammar: &'static str,
162 pub fixture_count: usize,
164 pub lane_count: usize,
166 pub metadata_count: usize,
168 pub marker_count: usize,
170 pub all_seeds_parse: bool,
172 pub reports: Vec<OmenaTestkitFixtureSeedReportV0>,
174}
175
176pub 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
207pub 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}