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 relation and artifact references
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        // Check art:artifact/@repo references a known ID (typically vcs:git/@id)
210        let art_ns = xot.add_namespace(namespace::ARTIFACT);
211        let artifact_tag = xot.add_name_ns("artifact", art_ns);
212        let repo_attr = xot.add_name("repo");
213
214        check_artifact_repo_refs(
215            &xot,
216            root,
217            artifact_tag,
218            repo_attr,
219            &all_ids,
220            &mut errors,
221        );
222    }
223
224    Ok(errors)
225}
226
227fn collect_all_ids(
228    xot: &Xot,
229    node: xot::Node,
230    id_attr: xot::NameId,
231    xml_id_attr: xot::NameId,
232    ids: &mut std::collections::HashSet<String>,
233) {
234    if xot.is_element(node) {
235        if let Some(id) = xot.get_attribute(node, id_attr) {
236            ids.insert(id.to_string());
237        }
238        if let Some(xml_id) = xot.get_attribute(node, xml_id_attr) {
239            ids.insert(xml_id.to_string());
240        }
241    }
242    for child in xot.children(node) {
243        collect_all_ids(xot, child, id_attr, xml_id_attr, ids);
244    }
245}
246
247fn check_artifact_repo_refs(
248    xot: &Xot,
249    node: xot::Node,
250    artifact_tag: xot::NameId,
251    repo_attr: xot::NameId,
252    all_ids: &std::collections::HashSet<String>,
253    errors: &mut Vec<ValidationError>,
254) {
255    if xot.is_element(node)
256        && xot.element(node).is_some_and(|e| e.name() == artifact_tag)
257        && let Some(repo) = xot.get_attribute(node, repo_attr)
258        && !all_ids.contains(repo)
259    {
260        errors.push(ValidationError {
261            message: format!(
262                "art:artifact repo=\"{repo}\" references unknown id \
263                 (add a vcs:git or other element with id=\"{repo}\")"
264            ),
265        });
266    }
267    for child in xot.children(node) {
268        check_artifact_repo_refs(xot, child, artifact_tag, repo_attr, all_ids, errors);
269    }
270}
271
272#[allow(clippy::too_many_arguments)]
273fn check_relation_refs(
274    xot: &Xot,
275    node: xot::Node,
276    relation_tag: xot::NameId,
277    from_attr: xot::NameId,
278    to_attr: xot::NameId,
279    to_spec_attr: xot::NameId,
280    all_ids: &std::collections::HashSet<String>,
281    errors: &mut Vec<ValidationError>,
282) {
283    if xot.is_element(node) && xot.element(node).is_some_and(|e| e.name() == relation_tag) {
284        // Skip cross-spec relations
285        if xot.get_attribute(node, to_spec_attr)
286            .is_none()
287        {
288            if let Some(from) = xot.get_attribute(node, from_attr)
289                && !all_ids.contains(from)
290                && !from.starts_with("type-")
291            {
292                errors.push(ValidationError {
293                    message: format!("relation from=\"{from}\" references nonexistent id"),
294                });
295            }
296            if let Some(to) = xot.get_attribute(node, to_attr)
297                && !all_ids.contains(to)
298                && !to.starts_with("type-")
299            {
300                errors.push(ValidationError {
301                    message: format!("relation to=\"{to}\" references nonexistent id"),
302                });
303            }
304        }
305    }
306    for child in xot.children(node) {
307        check_relation_refs(
308            xot,
309            child,
310            relation_tag,
311            from_attr,
312            to_attr,
313            to_spec_attr,
314            all_ids,
315            errors,
316        );
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::path::PathBuf;
324
325    fn spec_dir() -> PathBuf {
326        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
327            .join("../../clayers/clayers")
328            .canonicalize()
329            .expect("clayers/clayers/ not found")
330    }
331
332    #[test]
333    fn shipped_spec_passes_validation() {
334        let result = validate_spec(&spec_dir()).expect("validation failed");
335        assert!(
336            result.is_valid(),
337            "shipped spec should be valid, got errors: {:?}",
338            result.errors.iter().map(|e| &e.message).collect::<Vec<_>>()
339        );
340    }
341
342    #[test]
343    fn duplicate_id_detected() {
344        let dir = tempfile::tempdir().expect("tempdir");
345        let xml = r#"<?xml version="1.0"?>
346<spec:clayers xmlns:spec="urn:clayers:spec"
347              xmlns:idx="urn:clayers:index"
348              xmlns:pr="urn:clayers:prose">
349  <idx:file href="content.xml"/>
350</spec:clayers>"#;
351        std::fs::write(dir.path().join("index.xml"), xml).expect("write");
352
353        let content = r#"<?xml version="1.0"?>
354<spec:clayers xmlns:spec="urn:clayers:spec"
355              xmlns:pr="urn:clayers:prose"
356              spec:index="index.xml">
357  <pr:section id="dupe">first</pr:section>
358  <pr:section id="dupe">second</pr:section>
359</spec:clayers>"#;
360        std::fs::write(dir.path().join("content.xml"), content).expect("write");
361
362        let result = validate_spec(dir.path()).expect("validation failed");
363        assert!(!result.is_valid(), "duplicate IDs should fail validation");
364        assert!(
365            result
366                .errors
367                .iter()
368                .any(|e| e.message.contains("duplicate")),
369            "error message should mention duplicate"
370        );
371    }
372
373    #[test]
374    fn empty_dir_reports_no_index() {
375        let dir = tempfile::tempdir().expect("tempdir");
376        let result = validate_spec(dir.path()).expect("validation failed");
377        assert!(!result.is_valid());
378    }
379}