Skip to main content

clayers_spec/
validate.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use xot::Xot;
5
6use crate::namespace;
7
8/// Result of structural validation.
9#[derive(Debug)]
10pub struct ValidationResult {
11    pub spec_name: String,
12    pub file_count: usize,
13    pub errors: Vec<ValidationError>,
14}
15
16impl ValidationResult {
17    #[must_use]
18    pub fn is_valid(&self) -> bool {
19        self.errors.is_empty()
20    }
21}
22
23/// A structural validation error.
24#[derive(Debug)]
25pub struct ValidationError {
26    pub message: String,
27}
28
29/// Validate a spec structurally: well-formedness, ID uniqueness, cross-layer keyrefs.
30///
31/// Full XSD 1.1 validation is deferred (no Rust XSD 1.1 library exists).
32/// This implements structural checks that catch the most common errors.
33///
34/// # Errors
35///
36/// Returns an error if spec files cannot be discovered or read.
37pub fn validate_spec(spec_dir: &Path) -> Result<ValidationResult, crate::Error> {
38    let index_files = crate::discovery::find_index_files(spec_dir)?;
39
40    if index_files.is_empty() {
41        return Ok(ValidationResult {
42            spec_name: spec_dir.display().to_string(),
43            file_count: 0,
44            errors: vec![ValidationError {
45                message: "no index files found".into(),
46            }],
47        });
48    }
49
50    let mut all_errors = Vec::new();
51    let mut total_files = 0;
52    let mut spec_name = String::new();
53
54    for index_path in &index_files {
55        let file_paths = crate::discovery::discover_spec_files(index_path)?;
56        total_files += file_paths.len();
57
58        spec_name = index_path
59            .parent()
60            .and_then(|p| p.file_name())
61            .map_or_else(|| "unknown".into(), |n| n.to_string_lossy().into_owned());
62
63        // Check each file is well-formed XML
64        for file_path in &file_paths {
65            if let Err(e) = check_well_formed(file_path) {
66                all_errors.push(ValidationError {
67                    message: format!("{}: {e}", file_path.display()),
68                });
69            }
70        }
71
72        // Check ID uniqueness across all files
73        let id_errors = check_id_uniqueness(&file_paths)?;
74        all_errors.extend(id_errors);
75
76        // Check cross-layer references
77        let ref_errors = check_references(&file_paths)?;
78        all_errors.extend(ref_errors);
79    }
80
81    Ok(ValidationResult {
82        spec_name,
83        file_count: total_files,
84        errors: all_errors,
85    })
86}
87
88fn check_well_formed(path: &Path) -> Result<(), String> {
89    let content = std::fs::read_to_string(path).map_err(|e| e.to_string())?;
90    let mut xot = Xot::new();
91    xot.parse(&content).map_err(|e| e.to_string())?;
92    Ok(())
93}
94
95fn check_id_uniqueness(
96    file_paths: &[impl AsRef<Path>],
97) -> Result<Vec<ValidationError>, crate::Error> {
98    let mut seen: HashMap<String, String> = HashMap::new();
99    let mut errors = Vec::new();
100
101    for file_path in file_paths {
102        let file_path = file_path.as_ref();
103        let content = std::fs::read_to_string(file_path)?;
104        let mut xot = Xot::new();
105        let doc = xot.parse(&content).map_err(xot::Error::from)?;
106        let root = xot.document_element(doc)?;
107        let id_attr = xot.add_name("id");
108        let xml_ns = xot.add_namespace(namespace::XML);
109        let xml_id_attr = xot.add_name_ns("id", xml_ns);
110
111        collect_ids(
112            &xot,
113            root,
114            id_attr,
115            xml_id_attr,
116            file_path,
117            &mut seen,
118            &mut errors,
119        );
120    }
121
122    Ok(errors)
123}
124
125fn collect_ids(
126    xot: &Xot,
127    node: xot::Node,
128    id_attr: xot::NameId,
129    xml_id_attr: xot::NameId,
130    file_path: &Path,
131    seen: &mut HashMap<String, String>,
132    errors: &mut Vec<ValidationError>,
133) {
134    if xot.is_element(node) {
135        // Collect bare @id
136        if let Some(id) = xot.get_attribute(node, id_attr) {
137            let id = id.to_string();
138            let file_str = file_path.display().to_string();
139            if let Some(prev_file) = seen.get(&id) {
140                errors.push(ValidationError {
141                    message: format!(
142                        "duplicate id \"{id}\" (first in {prev_file}, also in {file_str})"
143                    ),
144                });
145            } else {
146                seen.insert(id, file_str);
147            }
148        }
149        // Collect xml:id (W3C standard, used by XMI/UML elements)
150        if let Some(xml_id) = xot.get_attribute(node, xml_id_attr) {
151            let xml_id = xml_id.to_string();
152            let file_str = file_path.display().to_string();
153            if let Some(prev_file) = seen.get(&xml_id) {
154                errors.push(ValidationError {
155                    message: format!(
156                        "duplicate id \"{xml_id}\" (first in {prev_file}, also in {file_str})"
157                    ),
158                });
159            } else {
160                seen.insert(xml_id, file_str);
161            }
162        }
163    }
164    for child in xot.children(node) {
165        collect_ids(xot, child, id_attr, xml_id_attr, file_path, seen, errors);
166    }
167}
168
169fn check_references(file_paths: &[impl AsRef<Path>]) -> Result<Vec<ValidationError>, crate::Error> {
170    // Collect all known IDs (both bare @id and xml:id)
171    let mut all_ids = std::collections::HashSet::new();
172    let mut errors = Vec::new();
173
174    for file_path in file_paths {
175        let content = std::fs::read_to_string(file_path.as_ref())?;
176        let mut xot = Xot::new();
177        let doc = xot.parse(&content).map_err(xot::Error::from)?;
178        let root = xot.document_element(doc)?;
179        let id_attr = xot.add_name("id");
180        let xml_ns = xot.add_namespace(namespace::XML);
181        let xml_id_attr = xot.add_name_ns("id", xml_ns);
182        collect_all_ids(&xot, root, id_attr, xml_id_attr, &mut all_ids);
183    }
184
185    // Check that relation from/to reference existing IDs
186    for file_path in file_paths {
187        let content = std::fs::read_to_string(file_path.as_ref())?;
188        let mut xot = Xot::new();
189        let doc = xot.parse(&content).map_err(xot::Error::from)?;
190        let root = xot.document_element(doc)?;
191
192        let relation_ns = xot.add_namespace(namespace::RELATION);
193        let relation_tag = xot.add_name_ns("relation", relation_ns);
194        let from_attr = xot.add_name("from");
195        let to_attr = xot.add_name("to");
196        let to_spec_attr = xot.add_name("to-spec");
197
198        check_relation_refs(
199            &xot,
200            root,
201            relation_tag,
202            from_attr,
203            to_attr,
204            to_spec_attr,
205            &all_ids,
206            &mut errors,
207        );
208    }
209
210    Ok(errors)
211}
212
213fn collect_all_ids(
214    xot: &Xot,
215    node: xot::Node,
216    id_attr: xot::NameId,
217    xml_id_attr: xot::NameId,
218    ids: &mut std::collections::HashSet<String>,
219) {
220    if xot.is_element(node) {
221        if let Some(id) = xot.get_attribute(node, id_attr) {
222            ids.insert(id.to_string());
223        }
224        if let Some(xml_id) = xot.get_attribute(node, xml_id_attr) {
225            ids.insert(xml_id.to_string());
226        }
227    }
228    for child in xot.children(node) {
229        collect_all_ids(xot, child, id_attr, xml_id_attr, ids);
230    }
231}
232
233#[allow(clippy::too_many_arguments)]
234fn check_relation_refs(
235    xot: &Xot,
236    node: xot::Node,
237    relation_tag: xot::NameId,
238    from_attr: xot::NameId,
239    to_attr: xot::NameId,
240    to_spec_attr: xot::NameId,
241    all_ids: &std::collections::HashSet<String>,
242    errors: &mut Vec<ValidationError>,
243) {
244    if xot.is_element(node) && xot.element(node).is_some_and(|e| e.name() == relation_tag) {
245        // Skip cross-spec relations
246        if xot.get_attribute(node, to_spec_attr)
247            .is_none()
248        {
249            if let Some(from) = xot.get_attribute(node, from_attr)
250                && !all_ids.contains(from)
251                && !from.starts_with("type-")
252            {
253                errors.push(ValidationError {
254                    message: format!("relation from=\"{from}\" references nonexistent id"),
255                });
256            }
257            if let Some(to) = xot.get_attribute(node, to_attr)
258                && !all_ids.contains(to)
259                && !to.starts_with("type-")
260            {
261                errors.push(ValidationError {
262                    message: format!("relation to=\"{to}\" references nonexistent id"),
263                });
264            }
265        }
266    }
267    for child in xot.children(node) {
268        check_relation_refs(
269            xot,
270            child,
271            relation_tag,
272            from_attr,
273            to_attr,
274            to_spec_attr,
275            all_ids,
276            errors,
277        );
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use std::path::PathBuf;
285
286    fn spec_dir() -> PathBuf {
287        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
288            .join("../../clayers/clayers")
289            .canonicalize()
290            .expect("clayers/clayers/ not found")
291    }
292
293    #[test]
294    fn shipped_spec_passes_validation() {
295        let result = validate_spec(&spec_dir()).expect("validation failed");
296        assert!(
297            result.is_valid(),
298            "shipped spec should be valid, got errors: {:?}",
299            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
300        );
301    }
302
303    #[test]
304    fn duplicate_id_detected() {
305        let dir = tempfile::tempdir().expect("tempdir");
306        let xml = r#"<?xml version="1.0"?>
307<spec:clayers xmlns:spec="urn:clayers:spec"
308              xmlns:idx="urn:clayers:index"
309              xmlns:pr="urn:clayers:prose">
310  <idx:file href="content.xml"/>
311</spec:clayers>"#;
312        std::fs::write(dir.path().join("index.xml"), xml).expect("write");
313
314        let content = r#"<?xml version="1.0"?>
315<spec:clayers xmlns:spec="urn:clayers:spec"
316              xmlns:pr="urn:clayers:prose"
317              spec:index="index.xml">
318  <pr:section id="dupe">first</pr:section>
319  <pr:section id="dupe">second</pr:section>
320</spec:clayers>"#;
321        std::fs::write(dir.path().join("content.xml"), content).expect("write");
322
323        let result = validate_spec(dir.path()).expect("validation failed");
324        assert!(!result.is_valid(), "duplicate IDs should fail validation");
325        assert!(
326            result
327                .errors
328                .iter()
329                .any(|e| e.message.contains("duplicate")),
330            "error message should mention duplicate"
331        );
332    }
333
334    #[test]
335    fn empty_dir_reports_no_index() {
336        let dir = tempfile::tempdir().expect("tempdir");
337        let result = validate_spec(dir.path()).expect("validation failed");
338        assert!(!result.is_valid());
339    }
340}