1use regex::Regex;
51use std::path::{Path, PathBuf};
52use std::process::Command;
53use std::time::{Duration, Instant};
54
55pub const DEFAULT_TIMEOUT_SECS: u64 = 90;
57
58const DIRECTIVE_KEYS: &[&str] = &[
60 "run",
61 "expect",
62 "expect-fail",
63 "expect_fail",
64 "requires",
65 "timeout",
66 "case",
67];
68
69pub struct VerifyCase {
71 pub name: String,
72 pub run_args: Vec<String>,
73 pub again: Vec<Vec<String>>,
81 pub session_cwd: bool,
88 pub expects: Vec<Regex>,
89 pub expect_fails: Vec<Regex>,
90 pub timeout: u64,
91}
92
93impl VerifyCase {
94 fn new(name: impl Into<String>) -> Self {
95 VerifyCase {
96 name: name.into(),
97 run_args: Vec::new(),
98 again: Vec::new(),
99 session_cwd: false,
100 expects: Vec::new(),
101 expect_fails: Vec::new(),
102 timeout: DEFAULT_TIMEOUT_SECS,
103 }
104 }
105}
106
107pub struct VerifyPlan {
109 pub requires: Option<String>,
111 pub cases: Vec<VerifyCase>,
112}
113
114impl VerifyPlan {
115 pub fn parse(src: &str) -> Result<VerifyPlan, String> {
117 let mut plan = parse_directives(src)?;
118 merge_verify_block(src, &mut plan)?;
119 Ok(plan)
120 }
121
122 pub fn is_empty(&self) -> bool {
124 self.requires.is_none() && self.cases.is_empty()
125 }
126}
127
128fn compile(v: &str) -> Result<Regex, String> {
129 Regex::new(v).map_err(|e| format!("bad regex {v:?}: {e}"))
130}
131
132fn parse_directives(src: &str) -> Result<VerifyPlan, String> {
135 let mut requires: Option<String> = None;
136 let mut cases: Vec<VerifyCase> = Vec::new();
137 let mut cur: Option<VerifyCase> = None;
138
139 for raw in src.lines() {
140 let line = raw.trim_start();
141 let Some(rest) = line.strip_prefix("#@") else {
142 continue;
143 };
144 let rest = rest.trim();
145 let (kw, val) = match rest.split_once(char::is_whitespace) {
146 Some((k, v)) => (k.trim_end_matches(':'), v.trim()),
147 None => (rest.trim_end_matches(':'), ""),
148 };
149 macro_rules! case {
150 () => {
151 cur.get_or_insert_with(|| VerifyCase::new("default"))
152 };
153 }
154 match kw {
155 "requires" => requires = Some(val.to_string()),
156 "case" => {
157 if let Some(c) = cur.take() {
158 cases.push(c);
159 }
160 cur = Some(VerifyCase::new(val));
161 }
162 "run" => case!().run_args = val.split_whitespace().map(String::from).collect(),
163 "again" => case!()
164 .again
165 .push(val.split_whitespace().map(String::from).collect()),
166 "session" => match val {
167 "cwd" => case!().session_cwd = true,
168 other => return Err(format!("unknown `#@ session {other}` (only `cwd`)")),
169 },
170 "expect" => case!().expects.push(compile(val)?),
171 "expect-fail" | "expect_fail" => case!().expect_fails.push(compile(val)?),
172 "timeout" => {
173 case!().timeout = val.parse().map_err(|_| format!("bad timeout {val:?}"))?
174 }
175 other => return Err(format!("unknown `#@ {other}` directive")),
176 }
177 }
178 if let Some(c) = cur.take() {
179 cases.push(c);
180 }
181 Ok(VerifyPlan { requires, cases })
182}
183
184fn merge_verify_block(src: &str, plan: &mut VerifyPlan) -> Result<(), String> {
187 let Ok(doc) = serde_yaml::from_str::<serde_yaml::Value>(src) else {
190 return Ok(());
191 };
192 let Some(block) = doc.get("verify") else {
193 return Ok(());
194 };
195 match block {
196 serde_yaml::Value::Sequence(items) => {
197 for (i, item) in items.iter().enumerate() {
198 plan.cases.push(case_from_map(item, None, i)?);
199 }
200 }
201 serde_yaml::Value::Mapping(m) => {
202 let all_directives = m
203 .keys()
204 .all(|k| k.as_str().is_some_and(|s| DIRECTIVE_KEYS.contains(&s)));
205 if all_directives {
206 if let Some(req) = m.get("requires").and_then(|v| v.as_str()) {
209 plan.requires = Some(req.to_string());
210 } else {
211 plan.cases.push(case_from_map(block, None, 0)?);
212 }
213 } else {
214 for (k, v) in m {
216 let name = k.as_str().ok_or("verify: case name must be a string")?;
217 plan.cases.push(case_from_map(v, Some(name), 0)?);
218 }
219 }
220 }
221 _ => return Err("verify: must be a map or a list of cases".to_string()),
222 }
223 Ok(())
224}
225
226fn case_from_map(
229 v: &serde_yaml::Value,
230 name_override: Option<&str>,
231 idx: usize,
232) -> Result<VerifyCase, String> {
233 let m = v.as_mapping().ok_or("verify: each case must be a map")?;
234 let get = |k: &str| m.get(serde_yaml::Value::from(k));
235 let name = name_override
236 .map(String::from)
237 .or_else(|| get("case").and_then(|x| x.as_str()).map(String::from))
238 .unwrap_or_else(|| format!("case-{idx}"));
239 let mut case = VerifyCase::new(name);
240 if let Some(run) = get("run").and_then(|x| x.as_str()) {
241 case.run_args = run.split_whitespace().map(String::from).collect();
242 }
243 for s in strings_of(get("expect")) {
244 case.expects.push(compile(&s)?);
245 }
246 for s in strings_of(get("expect-fail").or_else(|| get("expect_fail"))) {
247 case.expect_fails.push(compile(&s)?);
248 }
249 if let Some(t) = get("timeout").and_then(|x| x.as_u64()) {
250 case.timeout = t;
251 }
252 Ok(case)
253}
254
255fn strings_of(v: Option<&serde_yaml::Value>) -> Vec<String> {
257 match v {
258 Some(serde_yaml::Value::String(s)) => vec![s.clone()],
259 Some(serde_yaml::Value::Sequence(items)) => items
260 .iter()
261 .filter_map(|x| x.as_str().map(String::from))
262 .collect(),
263 _ => Vec::new(),
264 }
265}
266
267pub enum Outcome {
271 Pass,
272 Skip(String),
273 Fail(String),
274}
275
276pub fn run_case(
283 binary: &Path,
284 workload_ref: &str,
285 sandbox: &Path,
286 label: &str,
287 case: &VerifyCase,
288) -> Result<(), String> {
289 let safe_label = label.replace(['/', ' ', ':'], "_");
290 let session = sandbox.join(format!("session-{safe_label}"));
291 let case_cwd;
298 let workdir: &Path = if case.session_cwd {
299 case_cwd = sandbox.join(format!("cwd-{safe_label}"));
300 let _ = std::fs::remove_dir_all(&case_cwd);
301 std::fs::create_dir_all(&case_cwd).map_err(|e| format!("create case cwd: {e}"))?;
302 &case_cwd
303 } else {
304 let _ = std::fs::remove_dir_all(&session);
305 sandbox
306 };
307
308 let invoke = |args: &[String]| -> Result<(String, bool, bool), String> {
313 use std::io::Read as _;
314 use std::process::Stdio;
315 let mut cmd = Command::new(binary);
316 cmd.arg("run")
317 .arg(format!("workload={workload_ref}"))
318 .args(args);
319 if !case.session_cwd {
320 cmd.arg("--session-path").arg(&session);
321 }
322 let mut child = cmd
323 .current_dir(workdir)
324 .stdin(Stdio::null())
325 .stdout(Stdio::piped())
326 .stderr(Stdio::piped())
327 .spawn()
328 .map_err(|e| format!("spawn failed: {e}"))?;
329 let mut out_pipe = child.stdout.take().expect("stdout piped");
332 let mut err_pipe = child.stderr.take().expect("stderr piped");
333 let out_h = std::thread::spawn(move || {
334 let mut buf = Vec::new();
335 let _ = out_pipe.read_to_end(&mut buf);
336 buf
337 });
338 let err_h = std::thread::spawn(move || {
339 let mut buf = Vec::new();
340 let _ = err_pipe.read_to_end(&mut buf);
341 buf
342 });
343 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(case.timeout);
344 let (status, timed_out) = loop {
345 if let Some(st) = child.try_wait().map_err(|e| format!("wait failed: {e}"))? {
346 break (st, false);
347 }
348 if std::time::Instant::now() >= deadline {
349 let _ = child.kill();
350 let st = child.wait().map_err(|e| format!("wait failed: {e}"))?;
351 break (st, true);
352 }
353 std::thread::sleep(std::time::Duration::from_millis(25));
354 };
355 let stdout = out_h.join().unwrap_or_default();
356 let stderr = err_h.join().unwrap_or_default();
357 let combined = format!(
358 "{}{}",
359 String::from_utf8_lossy(&stdout),
360 String::from_utf8_lossy(&stderr)
361 );
362 Ok((combined, status.success(), timed_out))
363 };
364
365 let (mut combined, mut succeeded, mut timed_out) = invoke(&case.run_args)?;
366 for (i, extra) in case.again.iter().enumerate() {
367 if !succeeded || timed_out {
368 return Err(format!(
369 "invocation {} of {} failed before the `again` steps completed:
370{combined}",
371 i + 1,
372 case.again.len() + 1
373 ));
374 }
375 let (c, s, to) = invoke(extra)?;
376 combined.push_str(&c);
377 succeeded = s;
378 timed_out = to;
379 }
380 check_case_output(case, &combined, succeeded, timed_out)
381}
382
383pub fn check_case_output(
391 case: &VerifyCase,
392 combined: &str,
393 succeeded: bool,
394 timed_out: bool,
395) -> Result<(), String> {
396 if timed_out {
397 return Err(format!("timed out after {}s", case.timeout));
398 }
399 if case.expect_fails.is_empty() {
400 if !succeeded {
401 let err = combined
402 .lines()
403 .find(|l| l.contains("error:") || l.contains("panic"))
404 .unwrap_or("(no error line)");
405 return Err(format!("run failed (expected success): {err}"));
406 }
407 } else {
408 if succeeded {
409 return Err("expected a failure (`expect-fail`) but the run succeeded".to_string());
410 }
411 for re in &case.expect_fails {
412 if !re.is_match(combined) {
413 return Err(format!(
414 "expect-fail /{re}/ did not match the failure output"
415 ));
416 }
417 }
418 }
419 for re in &case.expects {
420 if !re.is_match(combined) {
421 return Err(format!("expect /{re}/ did not match the output"));
422 }
423 }
424 Ok(())
425}
426
427pub fn requires_verification_rules(run_ref: &str) -> bool {
438 std::path::Path::new(run_ref)
439 .components()
440 .any(|c| c.as_os_str() == "examples")
441}
442
443pub fn verify_source(
448 binary: &Path,
449 label_root: &str,
450 run_ref: &str,
451 rule_text: &str,
452 sandbox: &Path,
453) -> Vec<(String, Outcome)> {
454 let plan = match VerifyPlan::parse(rule_text) {
455 Ok(p) => p,
456 Err(e) => return vec![(label_root.to_string(), Outcome::Fail(e))],
457 };
458 if let Some(reason) = plan.requires {
459 return vec![(label_root.to_string(), Outcome::Skip(reason))];
460 }
461 if plan.cases.is_empty() {
462 return vec![(
474 label_root.to_string(),
475 if requires_verification_rules(run_ref) {
476 Outcome::Fail(
477 "no verification rules — every workload under `examples/` must \
478 declare them (add `#@ expect …` comments or a `verify:` block)"
479 .into(),
480 )
481 } else {
482 Outcome::Skip("no verification rules — nothing to check".into())
483 },
484 )];
485 }
486 plan.cases
487 .iter()
488 .map(|c| {
489 let label = format!("{label_root}::{}", c.name);
490 let outcome = match run_case(binary, run_ref, sandbox, &label, c) {
491 Ok(()) => Outcome::Pass,
492 Err(e) => Outcome::Fail(format!("{label}: {e}")),
493 };
494 (label, outcome)
495 })
496 .collect()
497}
498
499pub fn verify_file(
503 binary: &Path,
504 label_root: &str,
505 workload: &Path,
506 sandbox: &Path,
507) -> Vec<(String, Outcome)> {
508 let src = match std::fs::read_to_string(workload) {
509 Ok(s) => s,
510 Err(e) => {
511 return vec![(
512 label_root.to_string(),
513 Outcome::Fail(format!("read error: {e}")),
514 )];
515 }
516 };
517 let abs = workload
518 .canonicalize()
519 .unwrap_or_else(|_| workload.to_path_buf());
520 verify_source(binary, label_root, &abs.to_string_lossy(), &src, sandbox)
521}
522
523pub enum WorkloadSource {
526 File(PathBuf),
528 Catalog { name: String, source: String },
530}
531
532pub fn resolve_ref(reference: &str) -> Option<WorkloadSource> {
537 let p = Path::new(reference);
538 if p.is_file() {
539 return Some(WorkloadSource::File(
541 p.canonicalize().unwrap_or_else(|_| p.to_path_buf()),
542 ));
543 }
544 crate::catalog::lookup(reference).map(|w| WorkloadSource::Catalog {
545 name: w.name.to_string(),
546 source: w.source.to_string(),
547 })
548}
549
550pub fn declared_params(reference: &str) -> Option<std::collections::HashMap<String, String>> {
564 let merged = match resolve_ref(reference)? {
565 WorkloadSource::File(path) => crate::extends::load_and_merge(&path).ok()?.0,
569 WorkloadSource::Catalog { name, .. } => {
570 crate::extends::load_and_merge_bundled(crate::catalog::lookup(&name)?)
571 .ok()?
572 .0
573 }
574 };
575 let doc: serde_yaml::Value = serde_yaml::from_str(&merged).ok()?;
576 let params = doc.get("params")?.as_mapping()?;
577 let mut out = std::collections::HashMap::new();
578 for (k, v) in params {
579 let Some(key) = k.as_str() else { continue };
580 let val = match v {
583 serde_yaml::Value::String(s) => s.clone(),
584 serde_yaml::Value::Number(n) => n.to_string(),
585 serde_yaml::Value::Bool(b) => b.to_string(),
586 _ => continue,
587 };
588 out.insert(key.to_string(), val);
589 }
590 Some(out)
591}
592
593#[derive(Default)]
595pub struct VerifySummary {
596 pub passed: usize,
597 pub skipped: Vec<String>,
598 pub failures: Vec<String>,
599 pub timings: Vec<WorkloadTiming>,
602}
603
604#[derive(Debug, Clone, Copy, PartialEq, Eq)]
610pub enum CheckStatus {
611 Pass,
612 Skip,
613 Fail,
614}
615
616#[derive(Debug, Clone)]
618pub struct WorkloadTiming {
619 pub label: String,
620 pub elapsed: Duration,
621 pub status: CheckStatus,
622}
623
624#[derive(Debug, Clone)]
628pub enum CheckProgress {
629 Begin { total: usize },
631 Started { label: String },
633 Finished {
635 label: String,
636 elapsed: Duration,
637 status: CheckStatus,
638 },
639}
640
641pub type ProgressFn<'a> = dyn Fn(CheckProgress) + Sync + 'a;
643
644pub fn no_progress(_: CheckProgress) {}
646
647fn aggregate_status(outcomes: &[(String, Outcome)]) -> CheckStatus {
649 let mut saw_pass = false;
650 let mut saw_skip = false;
651 for (_, o) in outcomes {
652 match o {
653 Outcome::Fail(_) => return CheckStatus::Fail,
654 Outcome::Pass => saw_pass = true,
655 Outcome::Skip(_) => saw_skip = true,
656 }
657 }
658 if saw_skip && !saw_pass {
659 CheckStatus::Skip
660 } else {
661 CheckStatus::Pass
662 }
663}
664
665pub fn verify_path(
670 binary: &Path,
671 path: &Path,
672 sandbox: &Path,
673 progress: &ProgressFn,
674) -> VerifySummary {
675 let _ = std::fs::create_dir_all(sandbox);
676 let mut files: Vec<PathBuf> = Vec::new();
677 if path.is_dir() {
678 collect_yaml(path, &mut files);
679 files.sort();
680 } else {
681 files.push(path.to_path_buf());
682 }
683 progress(CheckProgress::Begin { total: files.len() });
684
685 let acc: std::sync::Mutex<VerifySummary> = std::sync::Mutex::new(VerifySummary::default());
686 let next = std::sync::atomic::AtomicUsize::new(0);
694 let workers = files.len().min(
695 std::thread::available_parallelism()
696 .map(|n| n.get())
697 .unwrap_or(4)
698 .saturating_mul(2)
699 .clamp(1, 16),
700 );
701 std::thread::scope(|s| {
702 for _ in 0..workers {
703 s.spawn(|| {
704 loop {
705 let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
706 let Some(f) = files.get(i) else { break };
707 let label = f
708 .file_name()
709 .and_then(|n| n.to_str())
710 .unwrap_or("?")
711 .to_string();
712 progress(CheckProgress::Started {
715 label: label.clone(),
716 });
717 let start = Instant::now();
718 let outcomes = verify_file(binary, &label, f, sandbox);
719 let elapsed = start.elapsed();
720 let status = aggregate_status(&outcomes);
721 let mut g = acc.lock().unwrap();
722 for (lbl, outcome) in outcomes {
723 match outcome {
724 Outcome::Pass => g.passed += 1,
725 Outcome::Skip(r) => g.skipped.push(format!("{lbl}: {r}")),
726 Outcome::Fail(m) => g.failures.push(m),
727 }
728 }
729 g.timings.push(WorkloadTiming {
730 label: label.clone(),
731 elapsed,
732 status,
733 });
734 drop(g);
735 progress(CheckProgress::Finished {
736 label,
737 elapsed,
738 status,
739 });
740 }
741 });
742 }
743 });
744 let mut sum = acc.into_inner().unwrap();
745 sum.skipped.sort();
746 sum.failures.sort();
747 sum
748}
749
750pub fn verify_target(
755 binary: &Path,
756 target: &str,
757 sandbox: &Path,
758 progress: &ProgressFn,
759) -> VerifySummary {
760 let p = Path::new(target);
761 if p.is_dir() {
762 return verify_path(binary, p, sandbox, progress);
763 }
764 let _ = std::fs::create_dir_all(sandbox);
765 progress(CheckProgress::Begin { total: 1 });
769 let label = match &resolve_ref(target) {
770 Some(WorkloadSource::File(path)) => path
771 .file_name()
772 .and_then(|n| n.to_str())
773 .unwrap_or(target)
774 .to_string(),
775 _ => target.to_string(),
776 };
777 progress(CheckProgress::Started {
778 label: label.clone(),
779 });
780 let start = Instant::now();
781 let cases: Vec<(String, Outcome)> = match resolve_ref(target) {
782 Some(WorkloadSource::File(path)) => verify_file(binary, &label, &path, sandbox),
783 Some(WorkloadSource::Catalog { name, source }) => {
784 verify_source(binary, &name, &name, &source, sandbox)
785 }
786 None => vec![(
787 target.to_string(),
788 Outcome::Fail(format!(
789 "no such workload '{target}': not a local file, not a directory, and \
790 no bundled workload by that name (try `nmbrs describe workloads --all`).{}",
791 crate::suggest::did_you_mean(&crate::suggest::suggest_workloads(target))
792 )),
793 )],
794 };
795 let elapsed = start.elapsed();
796 let status = aggregate_status(&cases);
797 let mut sum = VerifySummary::default();
798 for (lbl, outcome) in cases {
799 match outcome {
800 Outcome::Pass => sum.passed += 1,
801 Outcome::Skip(r) => sum.skipped.push(format!("{lbl}: {r}")),
802 Outcome::Fail(m) => sum.failures.push(m),
803 }
804 }
805 sum.timings.push(WorkloadTiming {
806 label: label.clone(),
807 elapsed,
808 status,
809 });
810 progress(CheckProgress::Finished {
811 label,
812 elapsed,
813 status,
814 });
815 sum
816}
817
818pub fn collect_workload_files(dir: &Path) -> Vec<PathBuf> {
823 let mut files = Vec::new();
824 collect_yaml(dir, &mut files);
825 files.sort();
826 files
827}
828
829fn collect_yaml(dir: &Path, out: &mut Vec<PathBuf>) {
830 let Ok(entries) = std::fs::read_dir(dir) else {
831 return;
832 };
833 for e in entries.flatten() {
834 let p = e.path();
835 if p.is_dir() {
836 collect_yaml(&p, out);
837 } else if p.extension().is_some_and(|x| x == "yaml" || x == "yml") {
838 out.push(p);
839 }
840 }
841}
842
843#[cfg(test)]
844mod rules_required_tests {
845 use super::requires_verification_rules;
846
847 #[test]
850 fn examples_require_rules() {
851 assert!(requires_verification_rules(
852 "/repo/examples/optimizer/control.yaml"
853 ));
854 assert!(requires_verification_rules("examples/w.yaml"));
855 assert!(requires_verification_rules(
856 "/repo/nmbrs/examples/modules/module_test.yaml"
857 ));
858 }
859
860 #[test]
863 fn other_locations_do_not_require_rules() {
864 assert!(!requires_verification_rules(
865 "/repo/nmbrs/workloads/cql/incremental_sweep.yaml"
866 ));
867 assert!(!requires_verification_rules("/tmp/scratch.yaml"));
868 assert!(!requires_verification_rules("some_catalog_name"));
869 }
870
871 #[test]
874 fn matches_path_components_not_substrings() {
875 assert!(!requires_verification_rules(
876 "/home/me/examples-scratch/w.yaml"
877 ));
878 assert!(!requires_verification_rules("/home/me/myexamples/w.yaml"));
879 assert!(requires_verification_rules(
880 "/home/me/examples/scratch/w.yaml"
881 ));
882 }
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888
889 #[test]
890 fn comment_and_yaml_forms_are_equivalent() {
891 let comment = "ops: { a: { raw: x } }\n#@ run cycles=3\n#@ expect 0 failed\n";
892 let single = "ops: { a: { raw: x } }\nverify: { run: cycles=3, expect: \"0 failed\" }\n";
893 let listed =
894 "ops: { a: { raw: x } }\nverify:\n - { run: cycles=3, expect: \"0 failed\" }\n";
895 let named =
896 "ops: { a: { raw: x } }\nverify:\n smoke: { run: cycles=3, expect: \"0 failed\" }\n";
897 for src in [comment, single, listed, named] {
898 let p = VerifyPlan::parse(src).expect("parse");
899 assert_eq!(p.cases.len(), 1, "one case for: {src}");
900 assert_eq!(p.cases[0].run_args, vec!["cycles=3"], "run for: {src}");
901 assert_eq!(p.cases[0].expects.len(), 1, "expect for: {src}");
902 }
903 }
904
905 #[test]
906 fn name_keyed_map_yields_named_cases() {
907 let src = "verify:\n alpha: { run: scenario=a, expect: \"x\" }\n beta: { expect: [\"y\", \"z\"] }\n";
908 let p = VerifyPlan::parse(src).unwrap();
909 let names: Vec<&str> = p.cases.iter().map(|c| c.name.as_str()).collect();
910 assert!(
911 names.contains(&"alpha") && names.contains(&"beta"),
912 "names: {names:?}"
913 );
914 let beta = p.cases.iter().find(|c| c.name == "beta").unwrap();
915 assert_eq!(beta.expects.len(), 2);
916 }
917
918 #[test]
919 fn requires_block_skips_the_file() {
920 let p = VerifyPlan::parse("verify: { requires: needs a backend }\n").unwrap();
921 assert_eq!(p.requires.as_deref(), Some("needs a backend"));
922 assert!(p.cases.is_empty());
923 }
924
925 #[test]
926 fn comment_and_block_cases_combine() {
927 let src = "#@ case fromcomment\n#@ expect a\nverify:\n fromblock: { expect: b }\n";
928 let p = VerifyPlan::parse(src).unwrap();
929 let names: Vec<&str> = p.cases.iter().map(|c| c.name.as_str()).collect();
930 assert!(
931 names.contains(&"fromcomment") && names.contains(&"fromblock"),
932 "{names:?}"
933 );
934 }
935
936 #[test]
937 fn resolve_ref_finds_files_and_rejects_unknown_names() {
938 let dir = std::env::temp_dir().join(format!("nmbrs-verify-resolve-{}", std::process::id()));
940 let _ = std::fs::create_dir_all(&dir);
941 let file = dir.join("w.yaml");
942 std::fs::write(&file, "ops: { a: { raw: x } }\n").unwrap();
943 match resolve_ref(file.to_str().unwrap()) {
944 Some(WorkloadSource::File(p)) => assert!(p.is_absolute(), "absolute: {p:?}"),
945 other => panic!(
946 "expected File, got {}",
947 matches!(other, Some(WorkloadSource::Catalog { .. })) as i32
948 ),
949 }
950 assert!(resolve_ref("definitely/not/a/workload").is_none());
953 let _ = std::fs::remove_dir_all(&dir);
954 }
955}