1use serde::Serialize;
2
3use crate::fixture::{OmenaFixtureFileV0, OmenaFixtureV0, parse_omena_fixture_v0};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
7#[serde(rename_all = "camelCase")]
8pub enum CmeScenarioArchetypeV0 {
9 Boundary,
12 TransformExecute,
15 LspScenario,
17 ShadowOmenaVerb,
19}
20
21impl CmeScenarioArchetypeV0 {
22 pub const fn id(self) -> &'static str {
24 match self {
25 Self::Boundary => "boundary",
26 Self::TransformExecute => "transform-execute",
27 Self::LspScenario => "lsp-scenario",
28 Self::ShadowOmenaVerb => "shadow-omena-verb",
29 }
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct CmeScenarioReadinessV0 {
37 pub fixture_parses: bool,
39 pub has_expected_products: bool,
41 pub has_required_files: bool,
43 pub has_required_markers: bool,
45 pub has_supported_introspection: bool,
48 pub ready: bool,
50 pub unsupported_reasons: Vec<&'static str>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct CmeScenarioCallSiteV0 {
58 pub file: &'static str,
60 pub line: u32,
62 pub column: u32,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
68#[serde(rename_all = "camelCase")]
69pub struct CmeScenarioV0 {
70 pub schema_version: &'static str,
72 pub product: &'static str,
74 pub fixture_grammar: &'static str,
76 pub archetype: CmeScenarioArchetypeV0,
78 pub archetype_id: &'static str,
80 pub file_count: usize,
82 pub source_file_count: usize,
84 pub style_file_count: usize,
86 pub expectation_count: usize,
88 pub marker_count: usize,
90 pub metadata_count: usize,
92 pub expected_products: Vec<String>,
94 pub shadow_omena_verbs: Vec<String>,
96 pub readiness: CmeScenarioReadinessV0,
98 pub call_site: Option<CmeScenarioCallSiteV0>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct OmenaTestkitScenarioMacroSeedReportV0 {
106 pub label: &'static str,
108 pub scenario: CmeScenarioV0,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct OmenaTestkitScenarioMacroCallSiteProbeV0 {
116 pub present: bool,
118 pub file_suffix_matches: bool,
120 pub line_non_zero: bool,
122 pub column_non_zero: bool,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct OmenaTestkitScenarioMacroReportV0 {
130 pub schema_version: &'static str,
132 pub product: &'static str,
134 pub fixture_grammar: &'static str,
136 pub supported_archetypes: Vec<&'static str>,
138 pub scenario_count: usize,
140 pub all_scenario_macros_ready: bool,
142 pub call_site_evidence_supported: bool,
144 pub call_site_probe: OmenaTestkitScenarioMacroCallSiteProbeV0,
146 pub reports: Vec<OmenaTestkitScenarioMacroSeedReportV0>,
148}
149
150struct OmenaTestkitScenarioMacroSeedV0 {
151 label: &'static str,
152 archetype: CmeScenarioArchetypeV0,
153 raw: &'static str,
154}
155
156const SCENARIO_MACRO_SEEDS: &[OmenaTestkitScenarioMacroSeedV0] = &[
157 OmenaTestkitScenarioMacroSeedV0 {
158 label: "boundary-product-scenario",
159 archetype: CmeScenarioArchetypeV0::Boundary,
160 raw: r#"--- file: src/Button.module.scss
161.button { color: red; }
162--- expect: product
163omena-parser.style-facts
164--- expect: assertion
165boundary scenario keeps the product expectation and workspace file together
166"#,
167 },
168 OmenaTestkitScenarioMacroSeedV0 {
169 label: "transform-execute-scenario",
170 archetype: CmeScenarioArchetypeV0::TransformExecute,
171 raw: r#"//- src/Button.module.scss dialect:scss layer:style
172.button { color: light-dark(red, blue); }
173--- expect: product
174omena-query.transform-execute
175--- expect: assertion
176transform-execute scenario carries a style fixture and transform product
177"#,
178 },
179 OmenaTestkitScenarioMacroSeedV0 {
180 label: "lsp-hover-scenario",
181 archetype: CmeScenarioArchetypeV0::LspScenario,
182 raw: r#"--- file: src/App.tsx
183import styles from "./Button.module.scss";
184export const App = () => <div className={styles./*|*/button} />;
185--- file: src/Button.module.scss
186.button { color: red; }
187--- expect: product
188omena-lsp-server.hover
189--- expect: assertion
190lsp scenario carries source, style, and request-position marker evidence
191"#,
192 },
193 OmenaTestkitScenarioMacroSeedV0 {
194 label: "shadow-omena-verb-scenario",
195 archetype: CmeScenarioArchetypeV0::ShadowOmenaVerb,
196 raw: r#"--- file: src/App.tsx
197import styles from "./Button.module.scss";
198const cls = styles.button;
199--- file: src/Button.module.scss
200.button { color: red; }
201--- expect: product
202shadow.omena.hover
203--- expect: product
204shadow.omena.definition
205--- expect: assertion
206shadow scenario exposes product-facing omena verbs without binding to one runner
207"#,
208 },
209];
210
211pub fn summarize_cme_scenario_v0(
213 raw: &str,
214 archetype: CmeScenarioArchetypeV0,
215) -> Result<CmeScenarioV0, String> {
216 let fixture = parse_omena_fixture_v0(raw)?;
217 Ok(build_cme_scenario_v0(fixture, archetype, None))
218}
219
220#[track_caller]
222pub fn summarize_cme_scenario_at_call_site_v0(
223 raw: &str,
224 archetype: CmeScenarioArchetypeV0,
225) -> Result<CmeScenarioV0, String> {
226 let caller = std::panic::Location::caller();
227 let call_site = CmeScenarioCallSiteV0 {
228 file: caller.file(),
229 line: caller.line(),
230 column: caller.column(),
231 };
232 let fixture = parse_omena_fixture_v0(raw)?;
233 Ok(build_cme_scenario_v0(fixture, archetype, Some(call_site)))
234}
235
236#[macro_export]
238macro_rules! cme_scenario_v0 {
239 (boundary, $raw:expr) => {
240 $crate::summarize_cme_scenario_at_call_site_v0(
241 $raw,
242 $crate::CmeScenarioArchetypeV0::Boundary,
243 )
244 };
245 (transform_execute, $raw:expr) => {
246 $crate::summarize_cme_scenario_at_call_site_v0(
247 $raw,
248 $crate::CmeScenarioArchetypeV0::TransformExecute,
249 )
250 };
251 (lsp_scenario, $raw:expr) => {
252 $crate::summarize_cme_scenario_at_call_site_v0(
253 $raw,
254 $crate::CmeScenarioArchetypeV0::LspScenario,
255 )
256 };
257 (shadow_omena, $raw:expr) => {
258 $crate::summarize_cme_scenario_at_call_site_v0(
259 $raw,
260 $crate::CmeScenarioArchetypeV0::ShadowOmenaVerb,
261 )
262 };
263}
264
265pub fn summarize_omena_testkit_scenario_macro_report() -> OmenaTestkitScenarioMacroReportV0 {
267 let call_site_probe = summarize_scenario_macro_call_site_probe();
268 let call_site_evidence_supported = call_site_probe.present
269 && call_site_probe.file_suffix_matches
270 && call_site_probe.line_non_zero
271 && call_site_probe.column_non_zero;
272 let reports = SCENARIO_MACRO_SEEDS
273 .iter()
274 .map(|seed| OmenaTestkitScenarioMacroSeedReportV0 {
275 label: seed.label,
276 scenario: summarize_cme_scenario_v0(seed.raw, seed.archetype)
277 .unwrap_or_else(|error| scenario_parse_error(seed.archetype, error)),
278 })
279 .collect::<Vec<_>>();
280 let all_scenario_macros_ready = reports.iter().all(|report| report.scenario.readiness.ready);
281
282 OmenaTestkitScenarioMacroReportV0 {
283 schema_version: "0",
284 product: "omena-testkit.scenario-macro-report",
285 fixture_grammar: "omena-fixture-v0",
286 supported_archetypes: vec![
287 CmeScenarioArchetypeV0::Boundary.id(),
288 CmeScenarioArchetypeV0::TransformExecute.id(),
289 CmeScenarioArchetypeV0::LspScenario.id(),
290 CmeScenarioArchetypeV0::ShadowOmenaVerb.id(),
291 ],
292 scenario_count: reports.len(),
293 all_scenario_macros_ready,
294 call_site_evidence_supported,
295 call_site_probe,
296 reports,
297 }
298}
299
300fn summarize_scenario_macro_call_site_probe() -> OmenaTestkitScenarioMacroCallSiteProbeV0 {
301 let scenario = crate::cme_scenario_v0!(boundary, SCENARIO_MACRO_SEEDS[0].raw).ok();
302 let call_site = scenario.and_then(|scenario| scenario.call_site);
303
304 OmenaTestkitScenarioMacroCallSiteProbeV0 {
305 present: call_site.is_some(),
306 file_suffix_matches: call_site
307 .as_ref()
308 .is_some_and(|site| site.file.ends_with("scenario.rs")),
309 line_non_zero: call_site.as_ref().is_some_and(|site| site.line > 0),
310 column_non_zero: call_site.as_ref().is_some_and(|site| site.column > 0),
311 }
312}
313
314fn build_cme_scenario_v0(
315 fixture: OmenaFixtureV0,
316 archetype: CmeScenarioArchetypeV0,
317 call_site: Option<CmeScenarioCallSiteV0>,
318) -> CmeScenarioV0 {
319 let source_file_count = fixture
320 .files
321 .iter()
322 .filter(|file| file_is_source(file))
323 .count();
324 let style_file_count = fixture
325 .files
326 .iter()
327 .filter(|file| file_is_style(file))
328 .count();
329 let expectation_count = fixture.expectations.len();
330 let marker_count = fixture.files.iter().map(|file| file.markers.len()).sum();
331 let metadata_count = fixture.files.iter().map(|file| file.metadata.len()).sum();
332 let expected_products = fixture
333 .expectations
334 .iter()
335 .filter(|expectation| expectation.key == "product")
336 .map(|expectation| expectation.value.clone())
337 .collect::<Vec<_>>();
338 let shadow_omena_verbs = shadow_omena_verbs_from_products(expected_products.as_slice());
339 let readiness = cme_scenario_readiness(
340 archetype,
341 style_file_count,
342 source_file_count,
343 marker_count,
344 expected_products.as_slice(),
345 shadow_omena_verbs.as_slice(),
346 );
347
348 CmeScenarioV0 {
349 schema_version: "0",
350 product: "omena-testkit.scenario",
351 fixture_grammar: "omena-fixture-v0",
352 archetype,
353 archetype_id: archetype.id(),
354 file_count: fixture.files.len(),
355 source_file_count,
356 style_file_count,
357 expectation_count,
358 marker_count,
359 metadata_count,
360 expected_products,
361 shadow_omena_verbs,
362 readiness,
363 call_site,
364 }
365}
366
367fn cme_scenario_readiness(
368 archetype: CmeScenarioArchetypeV0,
369 style_file_count: usize,
370 source_file_count: usize,
371 marker_count: usize,
372 expected_products: &[String],
373 shadow_omena_verbs: &[String],
374) -> CmeScenarioReadinessV0 {
375 let has_expected_products = !expected_products.is_empty();
376 let has_required_files = match archetype {
377 CmeScenarioArchetypeV0::Boundary => style_file_count + source_file_count > 0,
378 CmeScenarioArchetypeV0::TransformExecute => style_file_count > 0,
379 CmeScenarioArchetypeV0::LspScenario => style_file_count > 0 && source_file_count > 0,
380 CmeScenarioArchetypeV0::ShadowOmenaVerb => style_file_count + source_file_count > 0,
381 };
382 let has_required_markers = match archetype {
383 CmeScenarioArchetypeV0::LspScenario => marker_count > 0,
384 CmeScenarioArchetypeV0::Boundary
385 | CmeScenarioArchetypeV0::TransformExecute
386 | CmeScenarioArchetypeV0::ShadowOmenaVerb => true,
387 };
388 let has_supported_introspection = match archetype {
389 CmeScenarioArchetypeV0::Boundary => has_expected_products,
390 CmeScenarioArchetypeV0::TransformExecute => expected_products.iter().any(|product| {
391 product == "omena-query.transform-execute"
392 || product.starts_with("omena-transform-passes.")
393 }),
394 CmeScenarioArchetypeV0::LspScenario => expected_products
395 .iter()
396 .any(|product| product.starts_with("omena-lsp-server.")),
397 CmeScenarioArchetypeV0::ShadowOmenaVerb => !shadow_omena_verbs.is_empty(),
398 };
399 let mut unsupported_reasons = Vec::new();
400 if !has_expected_products {
401 unsupported_reasons.push("missingProductExpectation");
402 }
403 if !has_required_files {
404 unsupported_reasons.push("missingRequiredFiles");
405 }
406 if !has_required_markers {
407 unsupported_reasons.push("missingRequiredMarkers");
408 }
409 if !has_supported_introspection {
410 unsupported_reasons.push("unsupportedIntrospectionSurface");
411 }
412
413 CmeScenarioReadinessV0 {
414 fixture_parses: true,
415 has_expected_products,
416 has_required_files,
417 has_required_markers,
418 has_supported_introspection,
419 ready: unsupported_reasons.is_empty(),
420 unsupported_reasons,
421 }
422}
423
424fn shadow_omena_verbs_from_products(expected_products: &[String]) -> Vec<String> {
425 expected_products
426 .iter()
427 .filter_map(|product| product.strip_prefix("shadow.omena."))
428 .map(ToString::to_string)
429 .collect()
430}
431
432fn scenario_parse_error(archetype: CmeScenarioArchetypeV0, _error: String) -> CmeScenarioV0 {
433 CmeScenarioV0 {
434 schema_version: "0",
435 product: "omena-testkit.scenario",
436 fixture_grammar: "omena-fixture-v0",
437 archetype,
438 archetype_id: archetype.id(),
439 file_count: 0,
440 source_file_count: 0,
441 style_file_count: 0,
442 expectation_count: 0,
443 marker_count: 0,
444 metadata_count: 0,
445 expected_products: Vec::new(),
446 shadow_omena_verbs: Vec::new(),
447 readiness: CmeScenarioReadinessV0 {
448 fixture_parses: false,
449 has_expected_products: false,
450 has_required_files: false,
451 has_required_markers: false,
452 has_supported_introspection: false,
453 ready: false,
454 unsupported_reasons: vec!["fixtureParseError"],
455 },
456 call_site: None,
457 }
458}
459
460fn file_is_source(file: &OmenaFixtureFileV0) -> bool {
461 metadata_value(file, "dialect")
462 .is_some_and(|dialect| matches!(dialect, "ts" | "tsx" | "js" | "jsx"))
463 || file.path.ends_with(".ts")
464 || file.path.ends_with(".tsx")
465 || file.path.ends_with(".js")
466 || file.path.ends_with(".jsx")
467 || file.path.ends_with(".mts")
468 || file.path.ends_with(".cts")
469}
470
471fn file_is_style(file: &OmenaFixtureFileV0) -> bool {
472 metadata_value(file, "dialect")
473 .is_some_and(|dialect| matches!(dialect, "css" | "scss" | "less"))
474 || file.path.ends_with(".css")
475 || file.path.ends_with(".scss")
476 || file.path.ends_with(".sass")
477 || file.path.ends_with(".less")
478}
479
480fn metadata_value<'a>(file: &'a OmenaFixtureFileV0, key: &str) -> Option<&'a str> {
481 file.metadata
482 .iter()
483 .find(|metadata| metadata.key == key)
484 .map(|metadata| metadata.value.as_str())
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn scenario_macro_builds_boundary_archetype_summary() -> Result<(), String> {
493 let scenario = crate::cme_scenario_v0!(
494 boundary,
495 r#"--- file: src/Button.module.css
496.button { color: red; }
497--- expect: product
498omena-parser.style-facts
499"#
500 )?;
501
502 assert_eq!(scenario.product, "omena-testkit.scenario");
503 assert_eq!(scenario.archetype_id, "boundary");
504 assert_eq!(scenario.style_file_count, 1);
505 assert_eq!(scenario.expected_products, vec!["omena-parser.style-facts"]);
506 assert!(scenario.shadow_omena_verbs.is_empty());
507 assert!(scenario.readiness.ready);
508 let call_site = scenario
509 .call_site
510 .as_ref()
511 .ok_or("macro scenario must carry call-site evidence")?;
512 assert!(call_site.file.ends_with("scenario.rs"));
513 assert!(call_site.line > 0);
514 assert!(call_site.column > 0);
515
516 Ok(())
517 }
518
519 #[test]
520 fn scenario_macro_builds_transform_execute_archetype_summary() -> Result<(), String> {
521 let scenario = crate::cme_scenario_v0!(
522 transform_execute,
523 r#"//- src/Button.module.scss dialect:scss
524.button { color: light-dark(red, blue); }
525--- expect: product
526omena-query.transform-execute
527"#
528 )?;
529
530 assert_eq!(scenario.archetype_id, "transform-execute");
531 assert_eq!(scenario.style_file_count, 1);
532 assert_eq!(scenario.metadata_count, 1);
533 assert!(
534 scenario
535 .expected_products
536 .contains(&"omena-query.transform-execute".to_string())
537 );
538 assert!(scenario.readiness.ready);
539 assert!(
540 scenario.call_site.is_some(),
541 "macro scenario must preserve call-site evidence"
542 );
543
544 Ok(())
545 }
546
547 #[test]
548 fn transform_execute_scenario_requires_transform_product() -> Result<(), String> {
549 let scenario = summarize_cme_scenario_v0(
550 r#"--- file: src/Button.module.scss
551.button { color: red; }
552--- expect: product
553omena-parser.style-facts
554"#,
555 CmeScenarioArchetypeV0::TransformExecute,
556 )?;
557
558 assert!(!scenario.readiness.ready);
559 assert_eq!(scenario.call_site, None);
560 assert_eq!(
561 scenario.readiness.unsupported_reasons,
562 vec!["unsupportedIntrospectionSurface"]
563 );
564
565 Ok(())
566 }
567
568 #[test]
569 fn scenario_macro_builds_lsp_archetype_summary() -> Result<(), String> {
570 let scenario = crate::cme_scenario_v0!(
571 lsp_scenario,
572 r#"--- file: src/App.tsx
573import styles from "./Button.module.scss";
574styles./*|*/button;
575--- file: src/Button.module.scss
576.button { color: red; }
577--- expect: product
578omena-lsp-server.definition
579"#
580 )?;
581
582 assert_eq!(scenario.archetype_id, "lsp-scenario");
583 assert_eq!(scenario.source_file_count, 1);
584 assert_eq!(scenario.style_file_count, 1);
585 assert_eq!(scenario.marker_count, 1);
586 assert!(scenario.readiness.has_required_markers);
587 assert!(scenario.readiness.has_supported_introspection);
588 assert!(scenario.readiness.ready);
589
590 Ok(())
591 }
592
593 #[test]
594 fn lsp_scenario_requires_request_marker() -> Result<(), String> {
595 let scenario = summarize_cme_scenario_v0(
596 r#"--- file: src/App.tsx
597import styles from "./Button.module.scss";
598styles.button;
599--- file: src/Button.module.scss
600.button { color: red; }
601--- expect: product
602omena-lsp-server.definition
603"#,
604 CmeScenarioArchetypeV0::LspScenario,
605 )?;
606
607 assert!(!scenario.readiness.ready);
608 assert_eq!(scenario.marker_count, 0);
609 assert_eq!(
610 scenario.readiness.unsupported_reasons,
611 vec!["missingRequiredMarkers"]
612 );
613
614 Ok(())
615 }
616
617 #[test]
618 fn scenario_macro_builds_shadow_omena_verb_summary() -> Result<(), String> {
619 let scenario = crate::cme_scenario_v0!(
620 shadow_omena,
621 r#"--- file: src/App.tsx
622import styles from "./Button.module.scss";
623styles.button;
624--- file: src/Button.module.scss
625.button { color: red; }
626--- expect: product
627shadow.omena.hover
628--- expect: product
629shadow.omena.references
630"#
631 )?;
632
633 assert_eq!(scenario.archetype_id, "shadow-omena-verb");
634 assert_eq!(scenario.shadow_omena_verbs, vec!["hover", "references"]);
635 assert!(scenario.readiness.has_supported_introspection);
636 assert!(scenario.readiness.ready);
637
638 Ok(())
639 }
640}