1use crate::syntax::validate_expect_expr;
8use crate::types::LoadedSpec;
9use crate::{AUTHORED_SPEC_VERSION, Result, SpecError, SpecWarning};
10use serde_json::Value;
11use serde_yaml_bw::Value as YamlValue;
12use std::collections::{HashMap, HashSet};
13use std::sync::OnceLock;
14
15const SCHEMA_JSON: &str = include_str!("schema/unit.spec.json");
17
18static COMPILED_SCHEMA: OnceLock<jsonschema::Validator> = OnceLock::new();
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
21pub struct ValidationOptions {
22 pub strict_deps: bool,
23 pub allow_unsafe_local_test_expect: bool,
24}
25
26impl ValidationOptions {
27 pub fn strict() -> Self {
28 Self {
29 strict_deps: true,
30 allow_unsafe_local_test_expect: false,
31 }
32 }
33}
34
35pub fn validate_schema(spec: &LoadedSpec) -> Result<()> {
37 validate_json_value(
38 &serde_json::to_value(&spec.spec).map_err(SpecError::Json)?,
39 &spec.source.file_path,
40 )
41}
42
43pub fn validate_raw_yaml(yaml_value: &YamlValue, file_path: &str) -> Result<()> {
48 let spec_json = serde_json::to_value(yaml_value).map_err(SpecError::Json)?;
49 validate_json_value(&spec_json, file_path)
50}
51
52fn compiled_schema() -> Result<&'static jsonschema::Validator> {
53 if let Some(schema) = COMPILED_SCHEMA.get() {
54 return Ok(schema);
55 }
56
57 let schema_json: Value = serde_json::from_str(SCHEMA_JSON).map_err(SpecError::Json)?;
58 let schema =
59 jsonschema::draft7::new(&schema_json).map_err(|e| SpecError::SchemaValidation {
60 message: format!("Schema compilation failed: {e}"),
61 path: "<schema>".to_string(),
62 })?;
63
64 let _ = COMPILED_SCHEMA.set(schema);
65
66 Ok(COMPILED_SCHEMA
67 .get()
68 .expect("COMPILED_SCHEMA must be set after successful compilation"))
69}
70
71fn humanize_validation_error(error: &jsonschema::ValidationError<'_>) -> String {
72 use jsonschema::error::ValidationErrorKind;
73
74 let field_path = error.instance_path.to_string();
75 let field_label = if field_path.is_empty() || field_path == "/" {
76 String::new()
77 } else {
78 format!(" at {field_path}")
79 };
80
81 match &error.kind {
82 ValidationErrorKind::Required { property } => {
83 format!("missing required field: {}{}", property, field_label)
84 }
85 ValidationErrorKind::AdditionalProperties { unexpected } => {
86 let fields = unexpected.iter().cloned().collect::<Vec<_>>().join(", ");
87 format!("unknown field{}: {}", field_label, fields)
88 }
89 ValidationErrorKind::Enum { .. } => {
90 format!("invalid value{}: {} — check allowed values", field_label, error)
91 }
92 ValidationErrorKind::Pattern { .. } => {
93 if field_path == "/id" || field_path.ends_with("/id") {
94 format!("invalid id format{}: use \"module/name\" (e.g., \"pricing/apply_tax\")", field_label)
95 } else {
96 format!("invalid format{}: {}", field_label, error)
97 }
98 }
99 _ => {
100 if field_path.is_empty() {
101 error.to_string()
102 } else {
103 format!("{} (at {})", error, field_path)
104 }
105 }
106 }
107}
108
109fn validate_json_value(spec_json: &Value, file_path: &str) -> Result<()> {
110 let schema = compiled_schema()?;
111
112 let validation_result = schema.validate(spec_json);
114
115 match validation_result {
116 Ok(()) => Ok(()),
117 Err(error) => Err(SpecError::SchemaValidation {
118 message: humanize_validation_error(&error),
119 path: file_path.to_string(),
120 }),
121 }
122}
123
124pub fn validate_semantic(spec: &LoadedSpec) -> Result<()> {
126 validate_semantic_with_options(spec, &ValidationOptions::strict())
127}
128
129pub fn validate_semantic_with_options(
131 spec: &LoadedSpec,
132 options: &ValidationOptions,
133) -> Result<()> {
134 validate_rust_keywords(&spec.spec.id, &spec.source.file_path)?;
136
137 for dep in &spec.spec.deps {
139 validate_rust_keywords(dep, &spec.source.file_path)?;
140 }
141
142 if let Some((dep1, dep2)) = crate::types::ResolvedSpec::has_dep_collision(&spec.spec.deps) {
144 return Err(SpecError::DepCollision {
145 dep1: dep1.clone(),
146 dep2: dep2.clone(),
147 fn_name: crate::types::ResolvedSpec::dep_fn_name(dep1).to_string(),
148 path: spec.source.file_path.clone(),
149 });
150 }
151
152 let has_use_stmt = spec.spec.body.rust.lines().any(|line| {
155 let trimmed = line.trim_start();
156 if trimmed.starts_with("use ") || trimmed.starts_with("use\t") {
158 return true;
159 }
160 if let Some(rest) = trimmed.strip_prefix("pub") {
162 let rest = rest
163 .trim_start_matches(|c: char| {
164 c == '(' || c == ')' || c.is_alphanumeric() || c == '_'
165 })
166 .trim_start();
167 if rest.starts_with("use ") || rest.starts_with("use\t") {
168 return true;
169 }
170 }
171 false
172 });
173 if has_use_stmt {
174 return Err(SpecError::UseStatementInBody {
175 path: spec.source.file_path.clone(),
176 });
177 }
178
179 validate_body_rust_block(spec)?;
180 validate_local_test_expects(spec, options)?;
181 validate_contract_input_types(spec)?;
182
183 Ok(())
184}
185
186fn validate_local_test_expects(spec: &LoadedSpec, options: &ValidationOptions) -> Result<()> {
187 let path = spec.source.file_path.clone();
188 let mut seen_ids = HashSet::new();
189 for test in &spec.spec.local_tests {
190 if !seen_ids.insert(test.id.as_str()) {
191 return Err(SpecError::DuplicateLocalTestId {
192 id: test.id.clone(),
193 path: path.clone(),
194 });
195 }
196
197 validate_expect_expr(test.expect.trim(), options.allow_unsafe_local_test_expect).map_err(
198 |err| SpecError::LocalTestExpectNotExpr {
199 id: test.id.clone(),
200 message: err.message(),
201 path: path.clone(),
202 },
203 )?;
204 }
205 Ok(())
206}
207
208fn validate_body_rust_block(spec: &LoadedSpec) -> Result<()> {
209 let path = spec.source.file_path.clone();
210 syn::parse_str::<syn::Block>(&spec.spec.body.rust).map_err(|_| {
211 if syn::parse_str::<syn::ItemFn>(&spec.spec.body.rust).is_ok() {
212 SpecError::BodyRustLooksLikeFnDeclaration { path }
213 } else {
214 SpecError::BodyRustMustBeBlock {
215 message: "body.rust must be a Rust block expression starting with `{`".to_string(),
216 path,
217 }
218 }
219 })?;
220 Ok(())
221}
222
223fn validate_contract_input_types(spec: &LoadedSpec) -> Result<()> {
224 let path = &spec.source.file_path;
225 if let Some(contract) = &spec.spec.contract {
226 if let Some(inputs) = &contract.inputs {
227 for (name, type_str) in inputs {
228 syn::parse_str::<syn::Ident>(name).map_err(|_| {
229 SpecError::ContractInputNameInvalid {
230 name: name.clone(),
231 message: "use a snake_case identifier (e.g. my_param)".to_string(),
232 path: path.clone(),
233 }
234 })?;
235 syn::parse_str::<syn::Type>(type_str).map_err(|err| {
236 SpecError::ContractTypeInvalid {
237 field: format!("inputs.{name}"),
238 type_str: type_str.clone(),
239 message: err.to_string(),
240 path: path.clone(),
241 }
242 })?;
243 }
244 }
245 if let Some(returns) = &contract.returns {
246 syn::parse_str::<syn::Type>(returns).map_err(|err| SpecError::ContractTypeInvalid {
247 field: "returns".to_string(),
248 type_str: returns.clone(),
249 message: err.to_string(),
250 path: path.clone(),
251 })?;
252 }
253 }
254 Ok(())
255}
256
257pub fn validate_rust_keywords(id: &str, file_path: &str) -> Result<()> {
259 for segment in id.split('/') {
260 if crate::types::is_rust_keyword(segment) {
261 return Err(SpecError::RustKeyword {
262 segment: segment.to_string(),
263 id: id.to_string(),
264 path: file_path.to_string(),
265 });
266 }
267 }
268 Ok(())
269}
270
271pub fn validate_no_duplicate_ids(specs: &[LoadedSpec]) -> Vec<SpecError> {
276 use std::collections::HashMap;
277 let mut seen: HashMap<String, String> = HashMap::new();
278 let mut errors = Vec::new();
279
280 for spec in specs {
281 if let Some(existing_file) = seen.get(&spec.spec.id) {
282 errors.push(SpecError::DuplicateId {
283 id: spec.spec.id.clone(),
284 file1: existing_file.clone(),
285 file2: spec.source.file_path.clone(),
286 });
287 } else {
288 seen.insert(spec.spec.id.clone(), spec.source.file_path.clone());
289 }
290 }
291
292 errors
293}
294
295pub fn check_spec_versions(specs: &[LoadedSpec]) -> Vec<SpecWarning> {
299 specs
300 .iter()
301 .filter(|s| s.spec.spec_version.is_none())
302 .map(|s| SpecWarning::MissingSpecVersion {
303 path: s.source.file_path.clone(),
304 version: AUTHORED_SPEC_VERSION,
305 })
306 .collect()
307}
308
309pub fn validate_deps_exist(specs: &[LoadedSpec]) -> (Vec<SpecError>, Vec<SpecWarning>) {
313 validate_deps_exist_with_options(specs, &ValidationOptions::strict())
314}
315
316pub fn validate_deps_exist_with_options(
317 specs: &[LoadedSpec],
318 options: &ValidationOptions,
319) -> (Vec<SpecError>, Vec<SpecWarning>) {
320 let mut ids = HashSet::<&str>::new();
321 for spec in specs {
322 ids.insert(spec.spec.id.as_str());
323 }
324
325 let mut errors = Vec::<SpecError>::new();
326 let mut warnings = Vec::<SpecWarning>::new();
327 for spec in specs {
328 for dep in &spec.spec.deps {
329 if !ids.contains(dep.as_str()) {
330 if options.strict_deps {
331 errors.push(SpecError::MissingDep {
332 dep: dep.clone(),
333 path: spec.source.file_path.clone(),
334 });
335 } else {
336 warnings.push(SpecWarning::MissingDep {
337 dep: dep.clone(),
338 path: spec.source.file_path.clone(),
339 });
340 }
341 }
342 }
343 }
344
345 let cycle_errors = detect_cycles(specs);
346 errors.extend(cycle_errors);
347
348 (errors, warnings)
349}
350
351fn dfs_cycle_check<'a>(
353 node_id: &'a str,
354 id_map: &HashMap<&'a str, &'a LoadedSpec>,
355 visited: &mut HashSet<String>,
356 in_stack: &mut HashSet<String>,
357 stack: &mut Vec<String>,
358 errors: &mut Vec<SpecError>,
359) {
360 in_stack.insert(node_id.to_string());
361 stack.push(node_id.to_string());
362
363 if let Some(spec) = id_map.get(node_id) {
364 for dep in &spec.spec.deps {
365 if !id_map.contains_key(dep.as_str()) {
366 continue;
368 }
369 if in_stack.contains(dep.as_str()) {
370 let cycle_start = stack
372 .iter()
373 .position(|n| n == dep)
374 .expect("dep in in_stack must be in stack");
375 let mut cycle_path: Vec<String> = stack[cycle_start..].to_vec();
376 cycle_path.push(dep.clone());
377 errors.push(SpecError::CyclicDep {
378 cycle_path,
379 path: spec.source.file_path.clone(),
380 });
381 } else if !visited.contains(dep.as_str()) {
382 dfs_cycle_check(dep, id_map, visited, in_stack, stack, errors);
383 }
384 }
385 }
386
387 stack.pop();
388 in_stack.remove(node_id);
389 visited.insert(node_id.to_string());
390}
391
392pub fn detect_cycles(specs: &[LoadedSpec]) -> Vec<SpecError> {
403 let id_map: HashMap<&str, &LoadedSpec> =
404 specs.iter().map(|s| (s.spec.id.as_str(), s)).collect();
405
406 let mut visited = HashSet::<String>::new();
407 let mut errors = Vec::<SpecError>::new();
408
409 for spec in specs {
410 if !visited.contains(&spec.spec.id) {
411 let mut in_stack = HashSet::<String>::new();
412 let mut stack = Vec::<String>::new();
413 dfs_cycle_check(
414 &spec.spec.id,
415 &id_map,
416 &mut visited,
417 &mut in_stack,
418 &mut stack,
419 &mut errors,
420 );
421 }
422 }
423
424 errors
425}
426
427pub fn validate_full(spec: &LoadedSpec) -> Result<()> {
429 validate_full_with_options(spec, &ValidationOptions::strict())
430}
431
432pub fn validate_full_with_options(spec: &LoadedSpec, options: &ValidationOptions) -> Result<()> {
434 validate_schema(spec)?;
435 validate_semantic_with_options(spec, options)?;
436 Ok(())
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::types::{Body, Contract, Intent, LocalTest, SpecSource, SpecStruct};
443
444 fn create_test_spec(id: &str, rust_body: &str) -> LoadedSpec {
445 LoadedSpec {
446 source: SpecSource {
447 file_path: format!("test/{}.unit.spec", id),
448 id: id.to_string(),
449 },
450 spec: SpecStruct {
451 id: id.to_string(),
452 kind: "function".to_string(),
453 intent: Intent {
454 why: format!("Test spec for {}", id),
455 },
456 contract: None,
457 deps: vec![],
458 imports: vec![],
459 body: Body {
460 rust: rust_body.to_string(),
461 },
462 local_tests: vec![],
463 links: None,
464 spec_version: None,
465 },
466 }
467 }
468
469 #[test]
470 fn test_validate_schema_valid() {
471 let spec = create_test_spec("pricing/apply_discount", "{ }");
472 let result = validate_schema(&spec);
473 assert!(
474 result.is_ok(),
475 "Schema validation should pass: {:?}",
476 result
477 );
478 }
479
480 #[test]
481 fn test_validate_schema_valid_spec_passes() {
482 let spec = create_test_spec("pricing/apply_discount", "{ }");
483 let result = validate_schema(&spec);
484 assert!(
485 result.is_ok(),
486 "A complete valid spec should pass schema validation: {:?}",
487 result
488 );
489 }
490
491 #[test]
492 fn test_validate_raw_yaml_rejects_unknown_fields() {
493 let yaml = r#"
494id: pricing/apply_discount
495kind: function
496intent:
497 why: Apply a percentage discount.
498body:
499 rust: |
500 pub fn apply_discount() {}
501extra_field: should_fail
502"#;
503 let value: YamlValue = serde_yaml_bw::from_str(yaml).unwrap();
504
505 let result = validate_raw_yaml(&value, "test.unit.spec");
506 assert!(result.is_err());
507 let err = result.unwrap_err().to_string();
508 assert!(err.contains("Schema validation failed"));
509 assert!(err.contains("unknown field"));
510 }
511
512 #[test]
513 fn imports_field_validates_rust_path() {
514 let valid = r#"
515id: pricing/apply_discount
516kind: function
517intent:
518 why: Apply a percentage discount.
519imports:
520 - rust_decimal::Decimal
521 - std::collections::HashMap
522body:
523 rust: |
524 pub fn apply_discount() {}
525"#;
526 let value: YamlValue = serde_yaml_bw::from_str(valid).unwrap();
527 let result = validate_raw_yaml(&value, "test.unit.spec");
528 assert!(
529 result.is_ok(),
530 "Expected valid imports to pass: {:?}",
531 result
532 );
533
534 let invalid_bare = r#"
535id: pricing/apply_discount
536kind: function
537intent:
538 why: Apply a percentage discount.
539imports:
540 - Decimal
541body:
542 rust: |
543 pub fn apply_discount() {}
544"#;
545 let value: YamlValue = serde_yaml_bw::from_str(invalid_bare).unwrap();
546 let result = validate_raw_yaml(&value, "test.unit.spec");
547 assert!(result.is_err(), "Expected bare import to fail");
548
549 let invalid_leading = r#"
550id: pricing/apply_discount
551kind: function
552intent:
553 why: Apply a percentage discount.
554imports:
555 - ::Decimal
556body:
557 rust: |
558 pub fn apply_discount() {}
559"#;
560 let value: YamlValue = serde_yaml_bw::from_str(invalid_leading).unwrap();
561 let result = validate_raw_yaml(&value, "test.unit.spec");
562 assert!(result.is_err(), "Expected leading :: import to fail");
563 }
564
565 #[test]
566 fn validate_local_test_id_must_be_valid_identifier() {
567 let invalid = r#"
568id: pricing/apply_discount
569kind: function
570intent:
571 why: Apply a discount.
572body:
573 rust: |
574 pub fn apply_discount() {}
575local_tests:
576 - id: some case!
577 expect: "true"
578"#;
579 let value: YamlValue = serde_yaml_bw::from_str(invalid).unwrap();
580 let result = validate_raw_yaml(&value, "test.unit.spec");
581 assert!(result.is_err(), "Expected invalid local_tests id to fail");
582
583 let valid = r#"
584id: pricing/apply_discount
585kind: function
586intent:
587 why: Apply a discount.
588body:
589 rust: |
590 pub fn apply_discount() {}
591local_tests:
592 - id: happy_path
593 expect: "true"
594"#;
595 let value: YamlValue = serde_yaml_bw::from_str(valid).unwrap();
596 let result = validate_raw_yaml(&value, "test.unit.spec");
597 assert!(result.is_ok(), "Expected valid local_tests id to pass");
598 }
599
600 #[test]
601 fn test_validate_rust_keywords_in_id() {
602 let result = validate_rust_keywords("pricing/type", "test.unit.spec");
603 assert!(result.is_err());
604 let err = result.unwrap_err();
605 assert!(err.to_string().contains("Rust reserved keyword"));
606 assert!(err.to_string().contains("type"));
607 }
608
609 #[test]
610 fn test_validate_valid_id() {
611 let result = validate_rust_keywords("pricing/apply_discount", "test.unit.spec");
612 assert!(result.is_ok());
613 }
614
615 #[test]
616 fn test_validate_no_duplicate_ids() {
617 let specs = vec![
618 create_test_spec("pricing/apply_discount", "{ }"),
619 create_test_spec("utils/round", "{ }"),
620 ];
621
622 let errors = validate_no_duplicate_ids(&specs);
623 assert!(errors.is_empty());
624 }
625
626 #[test]
627 fn test_validate_duplicate_ids() {
628 let specs = vec![
629 create_test_spec("pricing/apply_discount", "{ }"),
630 create_test_spec("pricing/apply_discount", "{ }"),
631 ];
632
633 let errors = validate_no_duplicate_ids(&specs);
634 assert_eq!(errors.len(), 1);
635 assert!(errors[0].to_string().contains("Duplicate ID"));
636 assert!(errors[0].to_string().contains("pricing/apply_discount"));
637 }
638
639 #[test]
640 fn test_validate_duplicate_ids_all_reported() {
641 let specs = vec![
642 create_test_spec("pricing/apply_discount", "{ }"),
643 create_test_spec("pricing/apply_discount", "{ }"),
644 create_test_spec("pricing/apply_discount", "{ }"),
645 ];
646
647 let errors = validate_no_duplicate_ids(&specs);
648 assert_eq!(errors.len(), 2, "all duplicate pairs should be reported");
649 }
650
651 #[test]
652 fn test_validate_dep_collision() {
653 let mut spec = create_test_spec("pricing/calculate_total", "{ round(1.5) }");
654 spec.spec.deps = vec!["money/round".to_string(), "utils/round".to_string()];
655
656 let result = validate_semantic(&spec);
657 assert!(result.is_err());
658 let err = result.unwrap_err();
659 assert!(err.to_string().contains("collision"));
660 assert!(err.to_string().contains("round"));
661 }
662
663 #[test]
664 fn test_validate_use_statement_in_body() {
665 let spec = LoadedSpec {
666 source: SpecSource {
667 file_path: "test.unit.spec".to_string(),
668 id: "test/test".to_string(),
669 },
670 spec: SpecStruct {
671 id: "test/test".to_string(),
672 kind: "function".to_string(),
673 intent: Intent {
674 why: "Test spec".to_string(),
675 },
676 contract: None,
677 deps: vec![],
678 imports: vec![],
679 body: Body {
680 rust: "use std::collections::HashMap; pub fn test() {}".to_string(),
681 },
682 local_tests: vec![],
683 links: None,
684 spec_version: None,
685 },
686 };
687
688 let result = validate_semantic(&spec);
689 assert!(result.is_err());
690 let err = result.unwrap_err();
691 assert!(
692 err.to_string()
693 .contains("body.rust must not contain use statements")
694 );
695 }
696
697 #[test]
698 fn test_validate_semantic_valid_spec() {
699 let spec = create_test_spec("pricing/apply_discount", "{ subtotal - subtotal * rate }");
700 let result = validate_semantic(&spec);
701 assert!(result.is_ok());
702 }
703
704 #[test]
705 fn test_validate_full() {
706 let spec = create_test_spec("utils/round", "{ x.floor() }");
707 let result = validate_full(&spec);
708 assert!(result.is_ok(), "Full validation should pass: {:?}", result);
709 }
710
711 #[test]
712 fn test_validate_use_statement_pub_use_in_body() {
713 let spec = LoadedSpec {
714 source: SpecSource {
715 file_path: "test.unit.spec".to_string(),
716 id: "test/func".to_string(),
717 },
718 spec: SpecStruct {
719 id: "test/func".to_string(),
720 kind: "function".to_string(),
721 intent: Intent {
722 why: "Test pub use".to_string(),
723 },
724 contract: None,
725 deps: vec![],
726 imports: vec![],
727 body: Body {
728 rust: "pub use std::collections::HashMap;\npub fn func() {}".to_string(),
729 },
730 local_tests: vec![],
731 links: None,
732 spec_version: None,
733 },
734 };
735 let result = validate_semantic(&spec);
736 assert!(result.is_err());
737 assert!(
738 result
739 .unwrap_err()
740 .to_string()
741 .contains("body.rust must not contain use statements")
742 );
743 }
744
745 #[test]
746 fn test_validate_rust_keyword_try_in_id() {
747 let result = validate_rust_keywords("pricing/try", "test.unit.spec");
749 assert!(result.is_err());
750 assert!(
751 result
752 .unwrap_err()
753 .to_string()
754 .contains("Rust reserved keyword")
755 );
756 }
757
758 #[test]
759 fn validate_body_fn_declaration_emits_migration_error() {
760 let spec = create_test_spec("pricing/apply_discount", "pub fn apply_discount() {}");
761 let err = validate_semantic(&spec).unwrap_err().to_string();
762 assert!(
763 err.contains("looks like a full function declaration"),
764 "{err}"
765 );
766 }
767
768 #[test]
769 fn validate_body_fn_declaration_with_args_emits_migration_error() {
770 let spec = create_test_spec(
771 "pricing/apply_discount",
772 "pub fn apply_discount(subtotal: Decimal, rate: Decimal) -> Decimal { subtotal }",
773 );
774 let err = validate_semantic(&spec).unwrap_err().to_string();
775 assert!(
776 err.contains("looks like a full function declaration"),
777 "{err}"
778 );
779 }
780
781 #[test]
782 fn validate_body_rust_block_valid() {
783 let spec = create_test_spec("pricing/apply_discount", "{ subtotal - subtotal * rate }");
784 let result = validate_semantic(&spec);
785 assert!(result.is_ok(), "{result:?}");
786 }
787
788 #[test]
789 fn validate_body_rust_invalid_block() {
790 let spec = create_test_spec("pricing/apply_discount", "not valid rust at all !!!");
791 let err = validate_semantic(&spec).unwrap_err().to_string();
792 assert!(
793 err.contains("body.rust must be a Rust block expression"),
794 "{err}"
795 );
796 }
797
798 #[test]
799 fn validate_body_with_macros_in_block_passes() {
800 let spec = create_test_spec(
801 "pricing/apply_discount",
802 r#"{
803 let _v = vec![1, 2, 3];
804 assert!(true);
805 todo!()
806}"#,
807 );
808 let result = validate_semantic(&spec);
809 assert!(result.is_ok(), "{result:?}");
810 }
811
812 #[test]
813 fn validate_local_test_expect_rejects_non_expression() {
814 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
815 let spec = LoadedSpec {
816 source: SpecSource {
817 file_path: "test.unit.spec".to_string(),
818 id: "pricing/apply_discount".to_string(),
819 },
820 spec: SpecStruct {
821 id: "pricing/apply_discount".to_string(),
822 kind: "function".to_string(),
823 intent: Intent {
824 why: "Apply a discount.".to_string(),
825 },
826 contract: None,
827 deps: vec![],
828 imports: vec![],
829 body: Body {
830 rust: "{ }".to_string(),
831 },
832 local_tests: vec![LocalTest {
833 id: "injection_attempt".to_string(),
834 expect: "true); } } mod evil { fn steal() {}".to_string(),
835 }],
836 links: None,
837 spec_version: None,
838 },
839 };
840 let err = validate_semantic(&spec).unwrap_err().to_string();
841 assert!(
842 err.contains("not a valid Rust expression"),
843 "expected injection to be rejected: {err}"
844 );
845 }
846
847 #[test]
848 fn validate_local_test_expect_accepts_valid_expression() {
849 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
850 let spec = LoadedSpec {
851 source: SpecSource {
852 file_path: "test.unit.spec".to_string(),
853 id: "pricing/apply_discount".to_string(),
854 },
855 spec: SpecStruct {
856 id: "pricing/apply_discount".to_string(),
857 kind: "function".to_string(),
858 intent: Intent {
859 why: "Apply a discount.".to_string(),
860 },
861 contract: None,
862 deps: vec![],
863 imports: vec![],
864 body: Body {
865 rust: "{ true }".to_string(),
866 },
867 local_tests: vec![LocalTest {
868 id: "happy_path".to_string(),
869 expect: "apply_discount() == true".to_string(),
870 }],
871 links: None,
872 spec_version: None,
873 },
874 };
875 assert!(validate_semantic(&spec).is_ok());
876 }
877
878 #[test]
879 fn validate_local_test_expect_allows_block_expr_when_configured() {
880 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
881 let spec = LoadedSpec {
882 source: SpecSource {
883 file_path: "test.unit.spec".to_string(),
884 id: "pricing/apply_discount".to_string(),
885 },
886 spec: SpecStruct {
887 id: "pricing/apply_discount".to_string(),
888 kind: "function".to_string(),
889 intent: Intent {
890 why: "Apply a discount.".to_string(),
891 },
892 contract: None,
893 deps: vec![],
894 imports: vec![],
895 body: Body {
896 rust: "{ true }".to_string(),
897 },
898 local_tests: vec![LocalTest {
899 id: "block_allowed".to_string(),
900 expect: "{ let ok = apply_discount(); ok }".to_string(),
901 }],
902 links: None,
903 spec_version: None,
904 },
905 };
906
907 let options = ValidationOptions {
908 strict_deps: true,
909 allow_unsafe_local_test_expect: true,
910 };
911 assert!(validate_semantic_with_options(&spec, &options).is_ok());
912 }
913
914 #[test]
915 fn validate_local_test_duplicate_ids_are_rejected() {
916 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
917 let spec = LoadedSpec {
918 source: SpecSource {
919 file_path: "test.unit.spec".to_string(),
920 id: "pricing/apply_discount".to_string(),
921 },
922 spec: SpecStruct {
923 id: "pricing/apply_discount".to_string(),
924 kind: "function".to_string(),
925 intent: Intent {
926 why: "Apply a discount.".to_string(),
927 },
928 contract: None,
929 deps: vec![],
930 imports: vec![],
931 body: Body {
932 rust: "{ true }".to_string(),
933 },
934 local_tests: vec![
935 LocalTest {
936 id: "happy_path".to_string(),
937 expect: "apply_discount()".to_string(),
938 },
939 LocalTest {
940 id: "happy_path".to_string(),
941 expect: "apply_discount()".to_string(),
942 },
943 ],
944 links: None,
945 spec_version: None,
946 },
947 };
948
949 let err = validate_semantic(&spec).unwrap_err().to_string();
950 assert!(
951 err.contains("duplicate local_tests id 'happy_path'"),
952 "{err}"
953 );
954 }
955
956 #[test]
957 fn validate_local_test_expect_rejects_block_expression() {
958 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
959 let spec = LoadedSpec {
960 source: SpecSource {
961 file_path: "test.unit.spec".to_string(),
962 id: "pricing/apply_discount".to_string(),
963 },
964 spec: SpecStruct {
965 id: "pricing/apply_discount".to_string(),
966 kind: "function".to_string(),
967 intent: Intent {
968 why: "Apply a discount.".to_string(),
969 },
970 contract: None,
971 deps: vec![],
972 imports: vec![],
973 body: Body {
974 rust: "{ true }".to_string(),
975 },
976 local_tests: vec![LocalTest {
977 id: "block_attempt".to_string(),
978 expect: "{ std::process::exit(1); true }".to_string(),
979 }],
980 links: None,
981 spec_version: None,
982 },
983 };
984 let err = validate_semantic(&spec).unwrap_err().to_string();
985 assert!(
986 err.contains("block, unsafe, closure"),
987 "expected block expression to be rejected: {err}"
988 );
989 }
990
991 #[test]
992 fn expect_with_unsafe_block_in_call_arg_is_rejected() {
993 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
994 let spec = LoadedSpec {
995 source: SpecSource {
996 file_path: "test.unit.spec".to_string(),
997 id: "pricing/apply_discount".to_string(),
998 },
999 spec: SpecStruct {
1000 id: "pricing/apply_discount".to_string(),
1001 kind: "function".to_string(),
1002 intent: Intent {
1003 why: "Apply a discount.".to_string(),
1004 },
1005 contract: None,
1006 deps: vec![],
1007 imports: vec![],
1008 body: Body {
1009 rust: "{ true }".to_string(),
1010 },
1011 local_tests: vec![LocalTest {
1012 id: "unsafe_in_call_arg".to_string(),
1013 expect: "f(unsafe { true })".to_string(),
1014 }],
1015 links: None,
1016 spec_version: None,
1017 },
1018 };
1019
1020 let err = validate_semantic(&spec).unwrap_err().to_string();
1021 assert!(
1022 err.contains("block, unsafe, closure"),
1023 "expected unsafe block in call arg to be rejected: {err}"
1024 );
1025 }
1026
1027 #[test]
1028 fn expect_with_block_in_binary_operand_is_rejected() {
1029 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1030 let spec = LoadedSpec {
1031 source: SpecSource {
1032 file_path: "test.unit.spec".to_string(),
1033 id: "pricing/apply_discount".to_string(),
1034 },
1035 spec: SpecStruct {
1036 id: "pricing/apply_discount".to_string(),
1037 kind: "function".to_string(),
1038 intent: Intent {
1039 why: "Apply a discount.".to_string(),
1040 },
1041 contract: None,
1042 deps: vec![],
1043 imports: vec![],
1044 body: Body {
1045 rust: "{ true }".to_string(),
1046 },
1047 local_tests: vec![LocalTest {
1048 id: "block_in_binary_operand".to_string(),
1049 expect: "true && { false }".to_string(),
1050 }],
1051 links: None,
1052 spec_version: None,
1053 },
1054 };
1055
1056 let err = validate_semantic(&spec).unwrap_err().to_string();
1057 assert!(
1058 err.contains("block, unsafe, closure"),
1059 "expected block expression in binary operand to be rejected: {err}"
1060 );
1061 }
1062
1063 #[test]
1064 fn expect_with_unsafe_block_in_method_call_arg_is_rejected() {
1065 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1066 let spec = LoadedSpec {
1067 source: SpecSource {
1068 file_path: "test.unit.spec".to_string(),
1069 id: "pricing/apply_discount".to_string(),
1070 },
1071 spec: SpecStruct {
1072 id: "pricing/apply_discount".to_string(),
1073 kind: "function".to_string(),
1074 intent: Intent {
1075 why: "Apply a discount.".to_string(),
1076 },
1077 contract: None,
1078 deps: vec![],
1079 imports: vec![],
1080 body: Body {
1081 rust: "{ true }".to_string(),
1082 },
1083 local_tests: vec![LocalTest {
1084 id: "unsafe_in_method_arg".to_string(),
1085 expect: "foo.bar(unsafe { true })".to_string(),
1086 }],
1087 links: None,
1088 spec_version: None,
1089 },
1090 };
1091
1092 let err = validate_semantic(&spec).unwrap_err().to_string();
1093 assert!(
1094 err.contains("block, unsafe, closure"),
1095 "expected unsafe block in method call arg to be rejected: {err}"
1096 );
1097 }
1098
1099 #[test]
1100 fn expect_with_unsafe_block_in_field_base_is_rejected() {
1101 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1102 let spec = LoadedSpec {
1103 source: SpecSource {
1104 file_path: "test.unit.spec".to_string(),
1105 id: "pricing/apply_discount".to_string(),
1106 },
1107 spec: SpecStruct {
1108 id: "pricing/apply_discount".to_string(),
1109 kind: "function".to_string(),
1110 intent: Intent {
1111 why: "Apply a discount.".to_string(),
1112 },
1113 contract: None,
1114 deps: vec![],
1115 imports: vec![],
1116 body: Body {
1117 rust: "{ true }".to_string(),
1118 },
1119 local_tests: vec![LocalTest {
1120 id: "unsafe_in_field_base".to_string(),
1121 expect: "(unsafe { foo }).field".to_string(),
1122 }],
1123 links: None,
1124 spec_version: None,
1125 },
1126 };
1127
1128 let err = validate_semantic(&spec).unwrap_err().to_string();
1129 assert!(
1130 err.contains("block, unsafe, closure"),
1131 "expected unsafe block in field base to be rejected: {err}"
1132 );
1133 }
1134
1135 #[test]
1136 fn expect_with_unsafe_block_in_index_is_rejected() {
1137 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1138 let spec = LoadedSpec {
1139 source: SpecSource {
1140 file_path: "test.unit.spec".to_string(),
1141 id: "pricing/apply_discount".to_string(),
1142 },
1143 spec: SpecStruct {
1144 id: "pricing/apply_discount".to_string(),
1145 kind: "function".to_string(),
1146 intent: Intent {
1147 why: "Apply a discount.".to_string(),
1148 },
1149 contract: None,
1150 deps: vec![],
1151 imports: vec![],
1152 body: Body {
1153 rust: "{ true }".to_string(),
1154 },
1155 local_tests: vec![LocalTest {
1156 id: "unsafe_in_index".to_string(),
1157 expect: "arr[unsafe { 0 }]".to_string(),
1158 }],
1159 links: None,
1160 spec_version: None,
1161 },
1162 };
1163
1164 let err = validate_semantic(&spec).unwrap_err().to_string();
1165 assert!(
1166 err.contains("block, unsafe, closure"),
1167 "expected unsafe block in index to be rejected: {err}"
1168 );
1169 }
1170
1171 #[test]
1172 fn expect_with_unsafe_block_in_unary_is_rejected() {
1173 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1174 let spec = LoadedSpec {
1175 source: SpecSource {
1176 file_path: "test.unit.spec".to_string(),
1177 id: "pricing/apply_discount".to_string(),
1178 },
1179 spec: SpecStruct {
1180 id: "pricing/apply_discount".to_string(),
1181 kind: "function".to_string(),
1182 intent: Intent {
1183 why: "Apply a discount.".to_string(),
1184 },
1185 contract: None,
1186 deps: vec![],
1187 imports: vec![],
1188 body: Body {
1189 rust: "{ true }".to_string(),
1190 },
1191 local_tests: vec![LocalTest {
1192 id: "unsafe_in_unary".to_string(),
1193 expect: "!(unsafe { true })".to_string(),
1194 }],
1195 links: None,
1196 spec_version: None,
1197 },
1198 };
1199
1200 let err = validate_semantic(&spec).unwrap_err().to_string();
1201 assert!(
1202 err.contains("block, unsafe, closure"),
1203 "expected unsafe block in unary operand to be rejected: {err}"
1204 );
1205 }
1206
1207 #[test]
1208 fn expect_with_unsafe_block_in_cast_is_rejected() {
1209 use crate::types::{Body, Intent, LocalTest, SpecSource, SpecStruct};
1210 let spec = LoadedSpec {
1211 source: SpecSource {
1212 file_path: "test.unit.spec".to_string(),
1213 id: "pricing/apply_discount".to_string(),
1214 },
1215 spec: SpecStruct {
1216 id: "pricing/apply_discount".to_string(),
1217 kind: "function".to_string(),
1218 intent: Intent {
1219 why: "Apply a discount.".to_string(),
1220 },
1221 contract: None,
1222 deps: vec![],
1223 imports: vec![],
1224 body: Body {
1225 rust: "{ true }".to_string(),
1226 },
1227 local_tests: vec![LocalTest {
1228 id: "unsafe_in_cast".to_string(),
1229 expect: "(unsafe { 0 }) as u64".to_string(),
1230 }],
1231 links: None,
1232 spec_version: None,
1233 },
1234 };
1235
1236 let err = validate_semantic(&spec).unwrap_err().to_string();
1237 assert!(
1238 err.contains("block, unsafe, closure"),
1239 "expected unsafe block in cast to be rejected: {err}"
1240 );
1241 }
1242
1243 fn make_spec_with_contract(
1246 inputs: Option<indexmap::IndexMap<String, String>>,
1247 returns: Option<&str>,
1248 ) -> LoadedSpec {
1249 LoadedSpec {
1250 source: SpecSource {
1251 file_path: "test/pricing/apply_tax.unit.spec".to_string(),
1252 id: "pricing/apply_tax".to_string(),
1253 },
1254 spec: SpecStruct {
1255 id: "pricing/apply_tax".to_string(),
1256 kind: "function".to_string(),
1257 intent: Intent {
1258 why: "Apply tax.".to_string(),
1259 },
1260 contract: Some(Contract {
1261 inputs,
1262 returns: returns.map(String::from),
1263 invariants: vec![],
1264 }),
1265 deps: vec![],
1266 imports: vec![],
1267 body: Body {
1268 rust: "{ () }".to_string(),
1269 },
1270 local_tests: vec![],
1271 links: None,
1272 spec_version: None,
1273 },
1274 }
1275 }
1276
1277 #[test]
1278 fn contract_type_validation_passes_for_valid_types() {
1279 let mut inputs = indexmap::IndexMap::new();
1280 inputs.insert("subtotal".to_string(), "Decimal".to_string());
1281 inputs.insert("rate".to_string(), "Decimal".to_string());
1282 let spec = make_spec_with_contract(Some(inputs), Some("Decimal"));
1283 assert!(validate_semantic(&spec).is_ok());
1284 }
1285
1286 #[test]
1287 fn contract_type_validation_passes_with_no_contract() {
1288 let spec = create_test_spec("money/round", "{ () }");
1289 assert!(validate_semantic(&spec).is_ok());
1290 }
1291
1292 #[test]
1293 fn contract_type_validation_rejects_invalid_input_type() {
1294 let mut inputs = indexmap::IndexMap::new();
1295 inputs.insert("amount".to_string(), "Strinng".to_string());
1296 inputs.insert("rate".to_string(), "Vec<".to_string());
1299 let spec = make_spec_with_contract(Some(inputs), Some("Decimal"));
1300 let err = validate_semantic(&spec).unwrap_err().to_string();
1301 assert!(
1302 err.contains("contract.inputs.rate") && err.contains("invalid Rust type"),
1303 "expected ContractTypeInvalid for inputs.rate: {err}"
1304 );
1305 }
1306
1307 #[test]
1308 fn contract_type_validation_rejects_invalid_return_type() {
1309 let mut inputs = indexmap::IndexMap::new();
1310 inputs.insert("subtotal".to_string(), "Decimal".to_string());
1311 let spec = make_spec_with_contract(Some(inputs), Some("Vec<"));
1312 let err = validate_semantic(&spec).unwrap_err().to_string();
1313 assert!(
1314 err.contains("contract.returns") && err.contains("invalid Rust type"),
1315 "expected ContractTypeInvalid for returns: {err}"
1316 );
1317 }
1318
1319 #[test]
1320 fn contract_type_validation_rejects_keyword_input_name() {
1321 let mut inputs = indexmap::IndexMap::new();
1322 inputs.insert("type".to_string(), "Decimal".to_string());
1323 let spec = make_spec_with_contract(Some(inputs), None);
1324 let err = validate_semantic(&spec).unwrap_err().to_string();
1325 assert!(
1326 err.contains("'type'") && err.contains("not a valid Rust identifier"),
1327 "expected ContractInputNameInvalid for keyword key: {err}"
1328 );
1329 }
1330
1331 #[test]
1332 fn contract_type_validation_rejects_hyphenated_input_name() {
1333 let mut inputs = indexmap::IndexMap::new();
1334 inputs.insert("bad-name".to_string(), "Decimal".to_string());
1335 let spec = make_spec_with_contract(Some(inputs), None);
1336 let err = validate_semantic(&spec).unwrap_err().to_string();
1337 assert!(
1338 err.contains("'bad-name'") && err.contains("snake_case"),
1339 "expected ContractInputNameInvalid for hyphenated key: {err}"
1340 );
1341 }
1342
1343 #[test]
1346 fn test_detect_cycles_no_cycle() {
1347 let mut a = create_test_spec("a/foo", "{ }");
1349 a.spec.deps = vec!["b/bar".to_string()];
1350 let mut b = create_test_spec("b/bar", "{ }");
1351 b.spec.deps = vec!["c/baz".to_string()];
1352 let c = create_test_spec("c/baz", "{ }");
1353 let errors = detect_cycles(&[a, b, c]);
1354 assert!(errors.is_empty(), "expected no cycle errors: {:?}", errors);
1355 }
1356
1357 #[test]
1358 fn test_detect_cycles_simple_cycle() {
1359 let mut a = create_test_spec("a/foo", "{ }");
1361 a.spec.deps = vec!["b/bar".to_string()];
1362 let mut b = create_test_spec("b/bar", "{ }");
1363 b.spec.deps = vec!["a/foo".to_string()];
1364 let errors = detect_cycles(&[a, b]);
1365 assert_eq!(
1366 errors.len(),
1367 1,
1368 "expected exactly one cycle error: {:?}",
1369 errors
1370 );
1371 match &errors[0] {
1372 SpecError::CyclicDep { cycle_path, .. } => {
1373 assert_eq!(cycle_path, &["a/foo", "b/bar", "a/foo"]);
1374 }
1375 other => panic!("expected CyclicDep, got {:?}", other),
1376 }
1377 }
1378
1379 #[test]
1380 fn test_detect_cycles_self_loop() {
1381 let mut a = create_test_spec("a/foo", "{ }");
1383 a.spec.deps = vec!["a/foo".to_string()];
1384 let errors = detect_cycles(&[a]);
1385 assert_eq!(
1386 errors.len(),
1387 1,
1388 "expected exactly one cycle error: {:?}",
1389 errors
1390 );
1391 match &errors[0] {
1392 SpecError::CyclicDep { cycle_path, .. } => {
1393 assert_eq!(cycle_path, &["a/foo", "a/foo"]);
1394 }
1395 other => panic!("expected CyclicDep, got {:?}", other),
1396 }
1397 }
1398
1399 #[test]
1400 fn test_detect_cycles_longer_cycle() {
1401 let mut a = create_test_spec("a/foo", "{ }");
1403 a.spec.deps = vec!["b/bar".to_string()];
1404 let mut b = create_test_spec("b/bar", "{ }");
1405 b.spec.deps = vec!["c/baz".to_string()];
1406 let mut c = create_test_spec("c/baz", "{ }");
1407 c.spec.deps = vec!["a/foo".to_string()];
1408 let errors = detect_cycles(&[a, b, c]);
1409 assert_eq!(
1410 errors.len(),
1411 1,
1412 "expected exactly one cycle error: {:?}",
1413 errors
1414 );
1415 match &errors[0] {
1416 SpecError::CyclicDep { cycle_path, .. } => {
1417 assert_eq!(cycle_path, &["a/foo", "b/bar", "c/baz", "a/foo"]);
1418 }
1419 other => panic!("expected CyclicDep, got {:?}", other),
1420 }
1421 }
1422
1423 #[test]
1424 fn test_detect_cycles_missing_dep_skipped() {
1425 let mut a = create_test_spec("a/foo", "{ }");
1427 a.spec.deps = vec!["b/bar".to_string()];
1428 let errors = detect_cycles(&[a]);
1429 assert!(
1430 errors.is_empty(),
1431 "expected no cycle errors for missing dep: {:?}",
1432 errors
1433 );
1434 }
1435
1436 #[test]
1437 fn test_detect_cycles_multiple_cycles() {
1438 let mut a = create_test_spec("a/foo", "{ }");
1440 a.spec.deps = vec!["b/bar".to_string()];
1441 let mut b = create_test_spec("b/bar", "{ }");
1442 b.spec.deps = vec!["a/foo".to_string()];
1443 let mut c = create_test_spec("c/baz", "{ }");
1444 c.spec.deps = vec!["d/qux".to_string()];
1445 let mut d = create_test_spec("d/qux", "{ }");
1446 d.spec.deps = vec!["c/baz".to_string()];
1447 let errors = detect_cycles(&[a, b, c, d]);
1448 assert_eq!(errors.len(), 2, "expected two cycle errors: {:?}", errors);
1449 for err in &errors {
1450 assert!(
1451 matches!(err, SpecError::CyclicDep { .. }),
1452 "expected CyclicDep, got {:?}",
1453 err
1454 );
1455 }
1456 }
1457
1458 #[test]
1459 fn test_detect_cycles_error_message_format() {
1460 let mut a = create_test_spec("money/round", "{ }");
1461 a.spec.deps = vec!["currency/convert".to_string()];
1462 let mut b = create_test_spec("currency/convert", "{ }");
1463 b.spec.deps = vec!["money/round".to_string()];
1464 let errors = detect_cycles(&[a, b]);
1465 assert_eq!(errors.len(), 1);
1466 assert!(
1467 errors[0].to_string().contains("cycle detected"),
1468 "{}",
1469 errors[0]
1470 );
1471 assert!(
1472 errors[0].to_string().contains("money/round"),
1473 "{}",
1474 errors[0]
1475 );
1476 assert!(
1477 errors[0].to_string().contains("currency/convert"),
1478 "{}",
1479 errors[0]
1480 );
1481 }
1482
1483 #[test]
1486 fn check_spec_versions_warns_on_missing() {
1487 let spec = create_test_spec("pricing/apply_discount", "{ }");
1488 assert!(spec.spec.spec_version.is_none());
1489 let warnings = check_spec_versions(&[spec]);
1490 assert_eq!(warnings.len(), 1);
1491 assert!(
1492 warnings[0].to_string().contains("spec_version not set"),
1493 "{}",
1494 warnings[0]
1495 );
1496 assert!(
1497 warnings[0]
1498 .to_string()
1499 .contains(&format!("spec_version: \"{AUTHORED_SPEC_VERSION}\"")),
1500 "{}",
1501 warnings[0]
1502 );
1503 }
1504
1505 #[test]
1506 fn check_spec_versions_no_warning_when_set() {
1507 let mut spec = create_test_spec("pricing/apply_discount", "{ }");
1508 spec.spec.spec_version = Some("0.3.0".to_string());
1509 let warnings = check_spec_versions(&[spec]);
1510 assert!(warnings.is_empty(), "expected no warnings: {:?}", warnings);
1511 }
1512
1513 #[test]
1514 fn check_spec_versions_partial_warning() {
1515 let spec_with = {
1516 let mut s = create_test_spec("pricing/apply_discount", "{ }");
1517 s.spec.spec_version = Some("0.3.0".to_string());
1518 s
1519 };
1520 let spec_without = create_test_spec("money/round", "{ }");
1521 let warnings = check_spec_versions(&[spec_with, spec_without]);
1522 assert_eq!(warnings.len(), 1);
1523 assert!(warnings[0].to_string().contains("money/round"));
1524 }
1525
1526 #[test]
1527 fn spec_version_round_trips_through_serde() {
1528 let yaml = r#"
1529id: pricing/apply_discount
1530kind: function
1531spec_version: "0.3.0"
1532intent:
1533 why: Apply a discount.
1534body:
1535 rust: |
1536 { }
1537"#;
1538 let spec: crate::types::SpecStruct = serde_yaml_bw::from_str(yaml).unwrap();
1539 assert_eq!(spec.spec_version, Some("0.3.0".to_string()));
1540 }
1541
1542 #[test]
1543 fn spec_version_absent_round_trips_as_none() {
1544 let yaml = r#"
1545id: pricing/apply_discount
1546kind: function
1547intent:
1548 why: Apply a discount.
1549body:
1550 rust: |
1551 { }
1552"#;
1553 let spec: crate::types::SpecStruct = serde_yaml_bw::from_str(yaml).unwrap();
1554 assert!(spec.spec_version.is_none());
1555 }
1556
1557 #[test]
1558 fn expect_deeply_nested_parens_are_rejected_at_depth_cap() {
1559 let nested = format!("{}true{}", "(".repeat(200), ")".repeat(200));
1560 let err = validate_semantic(&LoadedSpec {
1561 source: SpecSource {
1562 file_path: "test.unit.spec".to_string(),
1563 id: "pricing/apply_discount".to_string(),
1564 },
1565 spec: SpecStruct {
1566 id: "pricing/apply_discount".to_string(),
1567 kind: "function".to_string(),
1568 intent: Intent {
1569 why: "Apply a discount.".to_string(),
1570 },
1571 contract: None,
1572 deps: vec![],
1573 imports: vec![],
1574 body: Body {
1575 rust: "{ true }".to_string(),
1576 },
1577 local_tests: vec![LocalTest {
1578 id: "deep".to_string(),
1579 expect: nested,
1580 }],
1581 links: None,
1582 spec_version: None,
1583 },
1584 })
1585 .unwrap_err();
1586
1587 assert!(err.to_string().contains("maximum depth of 128"));
1588 }
1589
1590 #[test]
1591 fn humanize_validation_error_required_field() {
1592 let yaml = r#"id: pricing/apply_tax
1593kind: function
1594body:
1595 rust: "{ 42 }""#;
1596 let value: YamlValue = serde_yaml_bw::from_str(yaml).unwrap();
1597 let err = validate_raw_yaml(&value, "test.unit.spec").unwrap_err().to_string();
1598 assert!(err.contains("missing required field"), "got: {err}");
1599 assert!(err.contains("intent"), "got: {err}");
1600 }
1601
1602 #[test]
1603 fn humanize_validation_error_unknown_field() {
1604 let yaml = r#"id: pricing/apply_tax
1605kind: function
1606intent:
1607 why: test
1608body:
1609 rust: "{ 42 }"
1610extra_field: bad"#;
1611 let value: YamlValue = serde_yaml_bw::from_str(yaml).unwrap();
1612 let err = validate_raw_yaml(&value, "test.unit.spec").unwrap_err().to_string();
1613 assert!(err.contains("unknown field"), "got: {err}");
1614 assert!(err.contains("extra_field"), "got: {err}");
1615 }
1616
1617 #[test]
1618 fn humanize_validation_error_id_pattern() {
1619 let yaml = r#"id: BAD_FORMAT
1620kind: function
1621intent:
1622 why: test
1623body:
1624 rust: "{ 42 }""#;
1625 let value: YamlValue = serde_yaml_bw::from_str(yaml).unwrap();
1626 let err = validate_raw_yaml(&value, "test.unit.spec").unwrap_err().to_string();
1627 assert!(err.contains("invalid id format"), "got: {err}");
1628 assert!(err.contains("module/name"), "got: {err}");
1629 }
1630}