1use std::collections::HashMap;
2use std::path::Path;
3
4use xot::Xot;
5
6use crate::namespace;
7
8#[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#[derive(Debug)]
25pub struct ValidationError {
26 pub message: String,
27}
28
29pub 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 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 let id_errors = check_id_uniqueness(&file_paths)?;
74 all_errors.extend(id_errors);
75
76 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 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 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 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 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 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}