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 OmenaFixtureV0 {
23 pub schema_version: &'static str,
25 pub files: Vec<OmenaFixtureFileV0>,
27 pub expectations: Vec<OmenaFixtureExpectationV0>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct OmenaFixtureFileV0 {
35 pub path: String,
37 pub metadata: Vec<OmenaFixtureFileMetadataV0>,
39 pub markers: Vec<OmenaFixtureMarkerV0>,
41 pub source: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct OmenaFixtureFileMetadataV0 {
49 pub key: String,
51 pub value: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "camelCase")]
58pub struct OmenaFixtureMarkerV0 {
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 OmenaFixtureExpectationV0 {
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 OmenaFixtureExpectationKindV0 {
83 Product,
85 Assertion,
87 Diagnostic,
89 NoDiagnostic,
91 Count,
93 CascadeOutcome,
95 CascadeWitness,
97 BoundaryState,
99 Unknown,
101}
102
103impl OmenaFixtureExpectationV0 {
104 pub fn kind(&self) -> OmenaFixtureExpectationKindV0 {
107 omena_fixture_expectation_kind_from_key(&self.key)
108 }
109}
110
111pub fn omena_fixture_expectation_kind_from_key(key: &str) -> OmenaFixtureExpectationKindV0 {
113 match key.split_whitespace().next().unwrap_or_default() {
114 "product" => OmenaFixtureExpectationKindV0::Product,
115 "assertion" => OmenaFixtureExpectationKindV0::Assertion,
116 "diagnostic" => OmenaFixtureExpectationKindV0::Diagnostic,
117 "no-diagnostic" => OmenaFixtureExpectationKindV0::NoDiagnostic,
118 "count" => OmenaFixtureExpectationKindV0::Count,
119 "cascade-outcome" => OmenaFixtureExpectationKindV0::CascadeOutcome,
120 "cascade-witness" => OmenaFixtureExpectationKindV0::CascadeWitness,
121 "boundary-state" => OmenaFixtureExpectationKindV0::BoundaryState,
122 _ => OmenaFixtureExpectationKindV0::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 pub expectation_outcomes: Vec<crate::fixture_eval::OmenaFixtureExpectationOutcomeV0>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
164#[serde(rename_all = "camelCase")]
165pub struct OmenaTestkitFixtureSeedCorpusReportV0 {
166 pub schema_version: &'static str,
168 pub product: &'static str,
170 pub fixture_grammar: &'static str,
172 pub fixture_count: usize,
174 pub lane_count: usize,
176 pub metadata_count: usize,
178 pub marker_count: usize,
180 pub all_seeds_parse: bool,
182 pub reports: Vec<OmenaTestkitFixtureSeedReportV0>,
184}
185
186pub fn summarize_omena_testkit_fixture_seed_corpus(
188 seeds: &[OmenaTestkitFixtureSeedV0],
189) -> OmenaTestkitFixtureSeedCorpusReportV0 {
190 let reports = seeds
191 .iter()
192 .copied()
193 .map(report_fixture_seed)
194 .collect::<Vec<_>>();
195 let all_seeds_parse = reports.iter().all(|report| report.parses);
196 let lane_count = reports
197 .iter()
198 .map(|report| report.lane)
199 .collect::<BTreeSet<_>>()
200 .len();
201 let metadata_count = reports.iter().map(|report| report.metadata_count).sum();
202 let marker_count = reports.iter().map(|report| report.marker_count).sum();
203
204 OmenaTestkitFixtureSeedCorpusReportV0 {
205 schema_version: "0",
206 product: "omena-testkit.fixture-seed-corpus",
207 fixture_grammar: "omena-fixture-v0",
208 fixture_count: reports.len(),
209 lane_count,
210 metadata_count,
211 marker_count,
212 all_seeds_parse,
213 reports,
214 }
215}
216
217pub fn parse_omena_fixture_v0(raw: &str) -> Result<OmenaFixtureV0, String> {
219 let mut files = Vec::new();
220 let mut expectations = Vec::new();
221 let mut current_file: Option<OmenaFixtureFileV0> = None;
222 let mut current_expectation: Option<OmenaFixtureExpectationV0> = None;
223
224 for line in raw.lines() {
225 if let Some(header) = line
226 .strip_prefix("--- file:")
227 .or_else(|| line.strip_prefix("//-"))
228 {
229 finish_fixture_section(&mut files, current_file.take());
230 finish_fixture_section(&mut expectations, current_expectation.take());
231 let (path, metadata) = parse_omena_fixture_file_header(header.trim())?;
232 current_file = Some(OmenaFixtureFileV0 {
233 path,
234 metadata,
235 markers: Vec::new(),
236 source: String::new(),
237 });
238 continue;
239 }
240
241 if let Some(key) = line.strip_prefix("--- expect:") {
242 finish_fixture_section(&mut files, current_file.take());
243 finish_fixture_section(&mut expectations, current_expectation.take());
244 current_expectation = Some(OmenaFixtureExpectationV0 {
245 key: key.trim().to_string(),
246 value: String::new(),
247 });
248 continue;
249 }
250
251 if let Some(file) = current_file.as_mut() {
252 push_fixture_line(&mut file.source, line);
253 } else if let Some(expectation) = current_expectation.as_mut() {
254 push_fixture_line(&mut expectation.value, line);
255 } else if !line.trim().is_empty() {
256 return Err("fixture content must start with a file or expect marker".to_string());
257 }
258 }
259
260 finish_fixture_section(&mut files, current_file.take());
261 finish_fixture_section(&mut expectations, current_expectation.take());
262
263 for file in &mut files {
264 let (cleaned_source, markers) = extract_omena_fixture_markers(&file.source)?;
265 file.source = cleaned_source;
266 file.markers = markers;
267 }
268
269 if files.is_empty() {
270 return Err("fixture must contain at least one file section".to_string());
271 }
272 if expectations.is_empty() {
273 return Err("fixture must contain at least one expectation section".to_string());
274 }
275
276 Ok(OmenaFixtureV0 {
277 schema_version: "0",
278 files,
279 expectations,
280 })
281}
282
283fn report_fixture_seed(seed: OmenaTestkitFixtureSeedV0) -> OmenaTestkitFixtureSeedReportV0 {
284 match parse_omena_fixture_v0(seed.raw) {
285 Ok(fixture) => {
286 let metadata_count = fixture.files.iter().map(|file| file.metadata.len()).sum();
287 let marker_count = fixture.files.iter().map(|file| file.markers.len()).sum();
288 let expectation_outcomes =
292 crate::fixture_eval::evaluate_omena_fixture_v0(&fixture, &[], &[], &[]);
293 OmenaTestkitFixtureSeedReportV0 {
294 label: seed.label,
295 lane: seed.lane,
296 parses: true,
297 parse_error: None,
298 file_count: fixture.files.len(),
299 expectation_count: fixture.expectations.len(),
300 metadata_count,
301 marker_count,
302 expected_products: seed.expected_products.to_vec(),
303 promotion_target: seed.promotion_target,
304 expectation_outcomes,
305 }
306 }
307 Err(error) => OmenaTestkitFixtureSeedReportV0 {
308 label: seed.label,
309 lane: seed.lane,
310 parses: false,
311 parse_error: Some(error),
312 file_count: 0,
313 expectation_count: 0,
314 metadata_count: 0,
315 marker_count: 0,
316 expected_products: seed.expected_products.to_vec(),
317 promotion_target: seed.promotion_target,
318 expectation_outcomes: Vec::new(),
319 },
320 }
321}
322
323fn finish_fixture_section<T>(sections: &mut Vec<T>, current: Option<T>) {
324 if let Some(section) = current {
325 sections.push(section);
326 }
327}
328
329fn push_fixture_line(buffer: &mut String, line: &str) {
330 if !buffer.is_empty() {
331 buffer.push('\n');
332 }
333 buffer.push_str(line);
334}
335
336fn parse_omena_fixture_file_header(
337 header: &str,
338) -> Result<(String, Vec<OmenaFixtureFileMetadataV0>), String> {
339 let mut parts = header.split_whitespace();
340 let path = parts
341 .next()
342 .ok_or_else(|| "fixture file header must include a path".to_string())?;
343 if path.contains(':') {
344 return Err("fixture file header path must precede metadata".to_string());
345 }
346
347 let mut metadata = Vec::new();
348 for part in parts {
349 let Some((key, value)) = part.split_once(':') else {
350 return Err(format!("fixture metadata `{part}` must use key:value"));
351 };
352 validate_omena_fixture_metadata(key, value)?;
353 metadata.push(OmenaFixtureFileMetadataV0 {
354 key: key.to_string(),
355 value: value.to_string(),
356 });
357 }
358
359 Ok((path.to_string(), metadata))
360}
361
362fn validate_omena_fixture_metadata(key: &str, value: &str) -> Result<(), String> {
363 if value.is_empty() {
364 return Err(format!("fixture metadata `{key}` must have a value"));
365 }
366 match key {
367 "dialect" => match value {
368 "css" | "scss" | "less" => Ok(()),
369 _ => Err("fixture dialect metadata must be css, scss, or less".to_string()),
370 },
371 "layer" | "composes-from" | "consumer-of" => Ok(()),
372 _ => Err(format!("fixture metadata key `{key}` is not supported")),
373 }
374}
375
376fn extract_omena_fixture_markers(
377 source: &str,
378) -> Result<(String, Vec<OmenaFixtureMarkerV0>), String> {
379 let mut cleaned = String::new();
380 let mut markers = Vec::new();
381 let mut cursor = 0;
382
383 while let Some(relative_start) = source[cursor..].find("/*") {
384 let start = cursor + relative_start;
385 cleaned.push_str(&source[cursor..start]);
386 let Some(relative_end) = source[start + 2..].find("*/") else {
387 return Err("fixture marker comment is unterminated".to_string());
388 };
389 let end = start + 2 + relative_end + 2;
390 let body = &source[start + 2..end - 2];
391 if let Some(marker) = parse_omena_fixture_marker(body, cleaned.len())? {
392 markers.push(marker);
393 } else {
394 cleaned.push_str(&source[start..end]);
395 }
396 cursor = end;
397 }
398
399 cleaned.push_str(&source[cursor..]);
400 Ok((cleaned, markers))
401}
402
403fn parse_omena_fixture_marker(
404 body: &str,
405 byte_offset: usize,
406) -> Result<Option<OmenaFixtureMarkerV0>, String> {
407 if body == "|" {
408 return Ok(Some(omena_fixture_marker("cursor", None, byte_offset)));
409 }
410 if let Some(name) = body.strip_prefix("at:") {
411 return Ok(Some(omena_fixture_marker(
412 "namedPoint",
413 Some(validate_omena_fixture_marker_payload("at", name)?),
414 byte_offset,
415 )));
416 }
417 if let Some(name) = body
418 .strip_prefix("</")
419 .and_then(|name| name.strip_suffix('>'))
420 {
421 return Ok(Some(omena_fixture_marker(
422 "rangeEnd",
423 Some(validate_omena_fixture_marker_payload("range end", name)?),
424 byte_offset,
425 )));
426 }
427 if let Some(name) = body
428 .strip_prefix('<')
429 .and_then(|name| name.strip_suffix('>'))
430 {
431 return Ok(Some(omena_fixture_marker(
432 "rangeStart",
433 Some(validate_omena_fixture_marker_payload("range start", name)?),
434 byte_offset,
435 )));
436 }
437 if let Some(name) = body.strip_prefix("name:") {
438 return Ok(Some(omena_fixture_marker(
439 "nameAnchor",
440 Some(validate_omena_fixture_marker_payload("name", name)?),
441 byte_offset,
442 )));
443 }
444 if let Some(target) = body.strip_prefix("from:") {
445 return Ok(Some(omena_fixture_marker(
446 "linkEnd",
447 Some(validate_omena_fixture_marker_payload("from", target)?),
448 byte_offset,
449 )));
450 }
451 Ok(None)
452}
453
454fn omena_fixture_marker(
455 kind: &'static str,
456 name: Option<String>,
457 byte_offset: usize,
458) -> OmenaFixtureMarkerV0 {
459 OmenaFixtureMarkerV0 {
460 kind,
461 name,
462 byte_start: byte_offset,
463 byte_end: byte_offset,
464 }
465}
466
467fn validate_omena_fixture_marker_payload(kind: &str, value: &str) -> Result<String, String> {
468 if value.is_empty() {
469 return Err(format!("fixture marker `{kind}` must have a value"));
470 }
471 Ok(value.to_string())
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 const CROSS_LANGUAGE_FIXTURE: &str = r#"--- file: src/App.tsx
479import styles from "./Button.module.scss";
480styles.button;
481--- file: src/Button.module.scss
482.button { color: red; }
483--- expect: product
484omena-query.source-syntax-index
485--- expect: assertion
486shared fixture parser keeps source and style files in the same workspace fixture
487"#;
488
489 #[test]
490 fn parses_reusable_omena_fixture_v0_sections() -> Result<(), String> {
491 let fixture = parse_omena_fixture_v0(
492 r#"--- file: src/proof.css
493.a { color: red; }
494--- expect: product
495omena-transform-passes.cascade-proof-obligations
496--- expect: assertion
497proof obligations remain product-visible
498"#,
499 )?;
500
501 assert_eq!(fixture.schema_version, "0");
502 assert_eq!(fixture.files.len(), 1);
503 assert_eq!(fixture.files[0].path, "src/proof.css");
504 assert!(fixture.files[0].source.contains(".a"));
505 assert_eq!(fixture.expectations.len(), 2);
506 assert_eq!(fixture.expectations[0].key, "product");
507 assert_eq!(
508 fixture.expectations[0].value,
509 "omena-transform-passes.cascade-proof-obligations"
510 );
511
512 Ok(())
513 }
514
515 #[test]
516 fn keeps_source_and_style_files_in_one_workspace_fixture() -> Result<(), String> {
517 let fixture = parse_omena_fixture_v0(CROSS_LANGUAGE_FIXTURE)?;
518
519 assert_eq!(fixture.files.len(), 2);
520 assert_eq!(fixture.files[0].path, "src/App.tsx");
521 assert_eq!(fixture.files[1].path, "src/Button.module.scss");
522 assert!(
523 fixture
524 .expectations
525 .iter()
526 .any(|expectation| expectation.value == "omena-query.source-syntax-index")
527 );
528
529 Ok(())
530 }
531
532 #[test]
533 fn parses_omena_fixture_v0_metadata_and_markers() -> Result<(), String> {
534 let fixture = parse_omena_fixture_v0(
535 r#"//- src/Card.module.scss dialect:scss layer:style
536.card { color: /*|*/red; }
537.card/*at:selector*/ { color: blue; }
538.card { color: /*<colorRange>*/green/*</colorRange>*/; }
539.card { composes: item/*from:src/Base.module.scss#item*/; }
540--- expect: product
541omena-testkit.fixture-markers
542"#,
543 )?;
544
545 assert_eq!(fixture.files.len(), 1);
546 assert_eq!(fixture.files[0].path, "src/Card.module.scss");
547 assert_eq!(
548 fixture.files[0]
549 .metadata
550 .iter()
551 .map(|metadata| (metadata.key.as_str(), metadata.value.as_str()))
552 .collect::<Vec<_>>(),
553 vec![("dialect", "scss"), ("layer", "style")]
554 );
555 assert_eq!(
556 fixture.files[0]
557 .markers
558 .iter()
559 .map(|marker| (marker.kind, marker.name.as_deref()))
560 .collect::<Vec<_>>(),
561 vec![
562 ("cursor", None),
563 ("namedPoint", Some("selector")),
564 ("rangeStart", Some("colorRange")),
565 ("rangeEnd", Some("colorRange")),
566 ("linkEnd", Some("src/Base.module.scss#item"))
567 ]
568 );
569 assert!(fixture.files[0].source.contains(".card { color: red; }"));
570 assert!(!fixture.files[0].source.contains("/*|*/"));
571 assert_eq!(
572 fixture.files[0].markers[0].byte_start,
573 ".card { color: ".len()
574 );
575
576 Ok(())
577 }
578
579 #[test]
580 fn keeps_non_fixture_comments_in_source() -> Result<(), String> {
581 let fixture = parse_omena_fixture_v0(
582 r#"//- src/Card.module.css dialect:css
583.card { /* regular comment */ color: red; }
584--- expect: product
585omena-testkit.fixture-markers
586"#,
587 )?;
588
589 assert!(fixture.files[0].source.contains("/* regular comment */"));
590 assert!(fixture.files[0].markers.is_empty());
591
592 Ok(())
593 }
594
595 #[test]
596 fn classifies_m7_diagnostic_cascade_and_boundary_expectations() -> Result<(), String> {
597 let fixture = parse_omena_fixture_v0(
598 r#"//- src/Nested.module.scss dialect:scss
599.article {
600 &.box { &.fill { padding: 1px; } }
601}
602--- expect: diagnostic
603code: unreachableDeclaration
604range: colorRange
605--- expect: no-diagnostic unspecifiedCascadeTie
606--- expect: count unreachableDeclaration:0
607--- expect: cascade-outcome decl-1
608--- expect: cascade-witness decl-2
609--- expect: boundary-state ext-1 Resolved
610--- expect: boundary-state ext-2 Partial
611--- expect: boundary-state ext-3 Stale
612--- expect: boundary-state ext-4 Missing
613--- expect: boundary-state ext-5 Unresolved
614"#,
615 )?;
616
617 assert_eq!(
618 fixture
619 .expectations
620 .iter()
621 .map(OmenaFixtureExpectationV0::kind)
622 .collect::<Vec<_>>(),
623 vec![
624 OmenaFixtureExpectationKindV0::Diagnostic,
625 OmenaFixtureExpectationKindV0::NoDiagnostic,
626 OmenaFixtureExpectationKindV0::Count,
627 OmenaFixtureExpectationKindV0::CascadeOutcome,
628 OmenaFixtureExpectationKindV0::CascadeWitness,
629 OmenaFixtureExpectationKindV0::BoundaryState,
630 OmenaFixtureExpectationKindV0::BoundaryState,
631 OmenaFixtureExpectationKindV0::BoundaryState,
632 OmenaFixtureExpectationKindV0::BoundaryState,
633 OmenaFixtureExpectationKindV0::BoundaryState,
634 ]
635 );
636 assert_eq!(
637 fixture.expectations[1].key,
638 "no-diagnostic unspecifiedCascadeTie"
639 );
640 assert_eq!(
641 fixture.expectations[2].key,
642 "count unreachableDeclaration:0"
643 );
644 Ok(())
645 }
646
647 #[test]
648 fn rejects_unknown_fixture_metadata() {
649 let error = parse_omena_fixture_v0(
650 r#"//- src/Card.module.css unknown:value
651.card { color: red; }
652--- expect: product
653omena-testkit.fixture-markers
654"#,
655 )
656 .err();
657
658 assert_eq!(
659 error.as_deref(),
660 Some("fixture metadata key `unknown` is not supported")
661 );
662 }
663
664 #[test]
665 fn rejects_fixture_without_sections() {
666 let error = parse_omena_fixture_v0("plain text").err();
667
668 assert_eq!(
669 error.as_deref(),
670 Some("fixture content must start with a file or expect marker")
671 );
672 }
673
674 #[test]
675 fn rejects_fixture_without_expectations() {
676 let error = parse_omena_fixture_v0(
677 r#"--- file: src/Button.module.scss
678.button { color: red; }
679"#,
680 )
681 .err();
682
683 assert_eq!(
684 error.as_deref(),
685 Some("fixture must contain at least one expectation section")
686 );
687 }
688
689 #[test]
690 fn summarizes_external_fixture_seed_corpus() {
691 let seeds = [OmenaTestkitFixtureSeedV0 {
692 label: "external",
693 lane: "consumer",
694 raw: r#"--- file: src/input.css
695.x { color: red; }
696--- expect: product
697consumer.product
698"#,
699 expected_products: &["consumer.product"],
700 promotion_target: "omena-testkit/consumer",
701 }];
702
703 let report = summarize_omena_testkit_fixture_seed_corpus(&seeds);
704
705 assert_eq!(report.product, "omena-testkit.fixture-seed-corpus");
706 assert_eq!(report.fixture_count, 1);
707 assert_eq!(report.lane_count, 1);
708 assert!(report.all_seeds_parse);
709 assert_eq!(report.reports[0].file_count, 1);
710 assert_eq!(report.reports[0].expectation_count, 1);
711 }
712}