lexicon_spec/
validation.rs1use crate::contract::Contract;
2use crate::error::{SpecError, SpecResult};
3use crate::manifest::Manifest;
4use crate::version::SchemaVersion;
5
6pub fn validate_version(version: &SchemaVersion) -> SpecResult<()> {
8 if !version.is_compatible_with(&SchemaVersion::CURRENT)
9 && !SchemaVersion::CURRENT.is_compatible_with(version)
10 {
11 return Err(SpecError::IncompatibleVersion {
12 found: version.clone(),
13 expected: SchemaVersion::CURRENT,
14 });
15 }
16 Ok(())
17}
18
19pub fn slugify(title: &str) -> String {
23 let slug: String = title
24 .trim()
25 .to_lowercase()
26 .chars()
27 .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
28 .collect();
29
30 let mut result = String::new();
32 let mut prev_hyphen = true;
33 for c in slug.chars() {
34 if c == '-' {
35 if !prev_hyphen {
36 result.push('-');
37 }
38 prev_hyphen = true;
39 } else {
40 result.push(c);
41 prev_hyphen = false;
42 }
43 }
44 if result.ends_with('-') {
45 result.pop();
46 }
47 result
48}
49
50pub fn validate_contract_id(id: &str) -> SpecResult<()> {
52 if id.is_empty() {
53 return Err(SpecError::InvalidContractId { id: id.to_string() });
54 }
55 let valid = id
57 .chars()
58 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
59 && !id.starts_with('-')
60 && !id.ends_with('-')
61 && !id.contains("--");
62 if !valid {
63 return Err(SpecError::InvalidContractId { id: id.to_string() });
64 }
65 Ok(())
66}
67
68pub fn validate_contract(contract: &Contract) -> SpecResult<()> {
70 validate_version(&contract.schema_version)?;
71 validate_contract_id(&contract.id)?;
72
73 if contract.title.trim().is_empty() {
74 return Err(SpecError::MissingField {
75 field: "title".to_string(),
76 });
77 }
78
79 if contract.scope.trim().is_empty() {
80 return Err(SpecError::MissingField {
81 field: "scope".to_string(),
82 });
83 }
84
85 let mut seen_ids = std::collections::HashSet::new();
87 for inv in &contract.invariants {
88 if !seen_ids.insert(&inv.id) {
89 return Err(SpecError::DuplicateId {
90 id: inv.id.clone(),
91 });
92 }
93 }
94
95 {
97 let mut seen_api = std::collections::HashSet::new();
98 for api_ref in &contract.expected_api {
99 if !seen_api.insert(api_ref) {
100 return Err(SpecError::DuplicateId {
101 id: api_ref.clone(),
102 });
103 }
104 }
105 }
106
107 for sem in &contract.required_semantics {
109 if !seen_ids.insert(&sem.id) {
110 return Err(SpecError::DuplicateId {
111 id: sem.id.clone(),
112 });
113 }
114 }
115 for sem in &contract.forbidden_semantics {
116 if !seen_ids.insert(&sem.id) {
117 return Err(SpecError::DuplicateId {
118 id: sem.id.clone(),
119 });
120 }
121 }
122
123 Ok(())
124}
125
126pub fn validate_manifest(manifest: &Manifest) -> SpecResult<()> {
128 validate_version(&manifest.schema_version)?;
129
130 if manifest.project.name.trim().is_empty() {
131 return Err(SpecError::MissingField {
132 field: "project.name".to_string(),
133 });
134 }
135
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::contract::Contract;
143
144 #[test]
145 fn test_slugify() {
146 assert_eq!(slugify("Key-Value Store"), "key-value-store");
147 assert_eq!(slugify("My Awesome Library!"), "my-awesome-library");
148 assert_eq!(slugify(" Rate Limiter (v2) "), "rate-limiter-v2");
149 assert_eq!(slugify("simple"), "simple");
150 assert_eq!(slugify("UPPER CASE"), "upper-case");
151 assert_eq!(slugify("dots.and_underscores"), "dots-and-underscores");
152 assert!(validate_contract_id(&slugify("Key-Value Store")).is_ok());
154 assert!(validate_contract_id(&slugify("Rate Limiter (v2)")).is_ok());
155 }
156
157 #[test]
158 fn test_valid_contract_ids() {
159 assert!(validate_contract_id("key-value-store").is_ok());
160 assert!(validate_contract_id("parser").is_ok());
161 assert!(validate_contract_id("my-lib-v2").is_ok());
162 assert!(validate_contract_id("a").is_ok());
163 }
164
165 #[test]
166 fn test_invalid_contract_ids() {
167 assert!(validate_contract_id("").is_err());
168 assert!(validate_contract_id("-leading").is_err());
169 assert!(validate_contract_id("trailing-").is_err());
170 assert!(validate_contract_id("double--dash").is_err());
171 assert!(validate_contract_id("UPPERCASE").is_err());
172 assert!(validate_contract_id("has spaces").is_err());
173 assert!(validate_contract_id("has_underscores").is_err());
174 }
175
176 #[test]
177 fn test_validate_contract_missing_title() {
178 let contract = Contract::new_draft(
179 "test".to_string(),
180 "".to_string(),
181 "scope".to_string(),
182 );
183 let result = validate_contract(&contract);
184 assert!(result.is_err());
185 }
186
187 #[test]
188 fn test_validate_contract_duplicate_ids() {
189 let mut contract = Contract::new_draft(
190 "test".to_string(),
191 "Test".to_string(),
192 "scope".to_string(),
193 );
194 contract.invariants.push(crate::contract::Invariant {
195 id: "dup".to_string(),
196 description: "first".to_string(),
197 severity: crate::common::Severity::Required,
198 test_tags: Vec::new(),
199 });
200 contract.invariants.push(crate::contract::Invariant {
201 id: "dup".to_string(),
202 description: "second".to_string(),
203 severity: crate::common::Severity::Required,
204 test_tags: Vec::new(),
205 });
206 let result = validate_contract(&contract);
207 assert!(result.is_err());
208 }
209
210 #[test]
211 fn test_validate_valid_contract() {
212 let contract = Contract::new_draft(
213 "test".to_string(),
214 "Test Contract".to_string(),
215 "Test scope".to_string(),
216 );
217 assert!(validate_contract(&contract).is_ok());
218 }
219}