1use 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}