Skip to main content

spec_core/
validator.rs

1//! Validator module: Validate specs against JSON Schema and perform semantic checks
2//!
3//! Two-stage validation:
4//! 1. JSON Schema validation (using embedded unit.spec.json)
5//! 2. Semantic validation (Rust keywords, deps, etc.)
6
7use 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
15/// JSON Schema for unit.spec validation (embedded at compile time)
16const 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
35/// Validate a single spec against the JSON Schema
36pub 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
43/// Validate a raw YAML-authored value against the JSON Schema before deserialization.
44///
45/// This is the validation path used by the loader so that unknown fields and other
46/// authoring-time mistakes are rejected before serde can apply defaults or drop data.
47pub 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    // Validate against schema
119    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
130/// Perform semantic validation (Rust keywords, deps, etc.)
131pub fn validate_semantic(spec: &LoadedSpec) -> Result<()> {
132    validate_semantic_with_options(spec, &ValidationOptions::strict())
133}
134
135/// Perform semantic validation with explicit options.
136pub fn validate_semantic_with_options(
137    spec: &LoadedSpec,
138    options: &ValidationOptions,
139) -> Result<()> {
140    // Check if ID contains Rust reserved keywords
141    validate_rust_keywords(&spec.spec.id, &spec.source.file_path)?;
142
143    // Check dep IDs for Rust reserved keywords (would generate invalid use paths)
144    for dep in &spec.spec.deps {
145        validate_rust_keywords(dep, &spec.source.file_path)?;
146    }
147
148    // Check for dep fn_name collisions
149    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    // Check for use statements in body.rust (line-start to avoid false positives in comments).
159    // Also catches visibility-prefixed forms: `pub use`, `pub(crate) use`, etc.
160    let has_use_stmt = spec.spec.body.rust.lines().any(|line| {
161        let trimmed = line.trim_start();
162        // Plain `use` or `use<TAB>`
163        if trimmed.starts_with("use ") || trimmed.starts_with("use\t") {
164            return true;
165        }
166        // `pub use ...` / `pub(crate) use ...` / `pub(super) use ...`
167        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
263/// Check if any segment of an ID is a Rust reserved keyword
264pub 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
277/// Check for duplicate IDs across all loaded specs.
278///
279/// Returns all duplicate pairs, not just the first. Each additional file that
280/// shares an ID produces a separate error citing the original file as file1.
281pub 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
301/// Emit a warning for each spec that lacks a spec_version field.
302///
303/// Called after per-spec semantic validation; warnings are non-fatal.
304pub 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
315/// Validate that all internal deps referenced by loaded specs exist in the same spec set.
316///
317/// For M2, deps are always strict: any missing dep is an error.
318pub 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
357/// DFS helper for cycle detection. Mutates `visited`, `in_stack`, `stack`, and `errors` in place.
358fn 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                // Missing dep — already reported by validate_deps_exist; skip during DFS
373                continue;
374            }
375            if in_stack.contains(dep.as_str()) {
376                // Cycle found — reconstruct path from the point where dep appears on the stack
377                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
398/// Detect cycles in the dependency graph using depth-first search.
399///
400/// Cycles are always errors regardless of ValidationOptions — a cycle causes
401/// infinite recursion during graph resolution.
402///
403/// NOTE: Cycle detection is in-tree only. Deps that reference units outside this
404/// spec set (cross-library) are skipped during DFS — they are not in id_map.
405/// The cross-library dep schema is locked in DECISIONS.md as namespace-prefixed
406/// ids (for example `shared::money/round`), but cross-library cycle detection is
407/// still deferred until M5 implements cross-library loading and validation.
408pub 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
433/// Full validation (schema + semantic)
434pub fn validate_full(spec: &LoadedSpec) -> Result<()> {
435    validate_full_with_options(spec, &ValidationOptions::strict())
436}
437
438/// Full validation (schema + semantic) with explicit options.
439pub 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        // `try` is reserved since Rust 2018 and was previously missing from the list
754        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    // --- contract.inputs type validation ---
1250
1251    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        // "Strinng" is a valid identifier so syn parses it fine as a Type::Path.
1303        // Use something syntactically invalid to verify the error path:
1304        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    // --- cycle detection ---
1350
1351    #[test]
1352    fn test_detect_cycles_no_cycle() {
1353        // A → B → C (no cycle)
1354        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        // A → B → A
1366        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        // A → A
1388        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        // A → B → C → A
1408        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        // A deps B but B is not in the set — no cycle, just a missing dep
1432        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        // (A → B → A) and (C → D → C)
1445        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    // --- check_spec_versions ---
1490
1491    #[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}