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