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 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 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}