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