Skip to main content

spec_core/
normalizer.rs

1//! Normalizer module: Convert SpecStruct to ResolvedSpec (IR)
2//!
3//! Phase 3: Normalize parsed specs into canonical IR for generation.
4
5use crate::types::{ResolvedSpec, SpecStruct, is_rust_keyword};
6use crate::{Result, SpecError};
7
8pub fn normalize_spec(mut spec: SpecStruct) -> Result<ResolvedSpec> {
9    spec.id = canonicalize_id(&spec.id)?;
10    Ok(ResolvedSpec::from_spec(spec))
11}
12
13fn canonicalize_id(id: &str) -> Result<String> {
14    let trimmed = id.trim();
15    validate_canonical_id(trimmed)?;
16    Ok(trimmed.to_string())
17}
18
19fn validate_canonical_id(id: &str) -> Result<()> {
20    if !id.contains('/') {
21        return Err(SpecError::SemanticValidation {
22            message: format!("ID '{id}' must be hierarchical and contain '/'"),
23            path: String::new(),
24        });
25    }
26
27    for segment in id.split('/') {
28        let mut chars = segment.chars();
29        let Some(first) = chars.next() else {
30            return Err(SpecError::SemanticValidation {
31                message: format!("ID '{id}' contains an empty segment"),
32                path: String::new(),
33            });
34        };
35
36        if !first.is_ascii_lowercase() {
37            return Err(SpecError::SemanticValidation {
38                message: format!("ID segment '{segment}' must start with a lowercase ASCII letter"),
39                path: String::new(),
40            });
41        }
42
43        if !chars.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_') {
44            return Err(SpecError::SemanticValidation {
45                message: format!(
46                    "ID segment '{segment}' contains invalid characters; expected lowercase ASCII, digits, or '_'"
47                ),
48                path: String::new(),
49            });
50        }
51
52        if is_rust_keyword(segment) {
53            return Err(SpecError::SemanticValidation {
54                message: format!("ID segment '{segment}' is a Rust reserved keyword"),
55                path: String::new(),
56            });
57        }
58    }
59
60    Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66    use crate::types::{Body, Intent};
67
68    fn make_spec(id: &str) -> SpecStruct {
69        SpecStruct {
70            id: id.to_string(),
71            kind: "function".to_string(),
72            intent: Intent {
73                why: "Normalize this unit".to_string(),
74            },
75            contract: None,
76            deps: vec![],
77            imports: vec![],
78            body: Body {
79                rust: "pub fn apply_discount() {}".to_string(),
80            },
81            local_tests: vec![],
82            links: None,
83            spec_version: None,
84        }
85    }
86
87    #[test]
88    fn trims_id_before_building_ir() {
89        let resolved = normalize_spec(make_spec(" pricing/apply_discount \n")).unwrap();
90        assert_eq!(resolved.id, "pricing/apply_discount");
91        assert_eq!(resolved.fn_name, "apply_discount");
92        assert_eq!(resolved.module_path, "pricing");
93    }
94
95    #[test]
96    fn rejects_non_hierarchical_ids() {
97        let err = normalize_spec(make_spec("apply_discount")).unwrap_err();
98        assert!(err.to_string().contains("must be hierarchical"));
99    }
100
101    #[test]
102    fn rejects_invalid_segments() {
103        let err = normalize_spec(make_spec("pricing/ApplyDiscount")).unwrap_err();
104        assert!(
105            err.to_string()
106                .contains("must start with a lowercase ASCII letter")
107        );
108    }
109
110    #[test]
111    fn rejects_keywords_defensively() {
112        let err = normalize_spec(make_spec("pricing/type")).unwrap_err();
113        assert!(err.to_string().contains("Rust reserved keyword"));
114    }
115}