Skip to main content

lexicon_spec/
validation.rs

1use crate::contract::Contract;
2use crate::error::{SpecError, SpecResult};
3use crate::manifest::Manifest;
4use crate::version::SchemaVersion;
5
6/// Validate that a schema version is compatible with the current version.
7pub 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
19/// Convert a human-readable title into a kebab-case slug suitable as a contract ID.
20///
21/// Examples: "Key-Value Store" -> "key-value-store", "Rate Limiter (v2)" -> "rate-limiter-v2"
22pub 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    // Collapse multiple hyphens and trim leading/trailing
31    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
50/// Validate a contract id is a valid kebab-case slug.
51pub fn validate_contract_id(id: &str) -> SpecResult<()> {
52    if id.is_empty() {
53        return Err(SpecError::InvalidContractId { id: id.to_string() });
54    }
55    // Must be lowercase alphanumeric with hyphens, no leading/trailing hyphens
56    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
68/// Validate a contract for structural correctness.
69pub 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    // Check for duplicate invariant IDs
86    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    // Check for duplicate expected_api entries
96    {
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    // Check for duplicate semantic IDs
108    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
126/// Validate a manifest for structural correctness.
127pub 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        // Slugified titles should pass contract ID validation
153        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}