wowlab_tidy/languages/toml/rules/manifest/
repository.rs1use std::collections::{HashMap, HashSet};
4
5use wowlab_fs::{
6 directory, file,
7 path::{Path, PathBuf},
8};
9use wowlab_manifest_schema::ManifestRepository;
10
11use super::{ITEMS_REL, manifest_repository_root, workspace_relative};
12use crate::{Example, TomlCtx, Violation, violation};
13
14const EXAMPLES: &[Example] = &[];
15
16crate::toml_rule!(
17 toml_manifest_repository,
18 "Require a closed, unambiguous manifest component graph and unique spec IDs.",
19 "Missing, duplicate, or orphaned components make filesystem structure diverge from the manifests actually composed at build time.",
20 High,
21);
22
23fn check_toml_manifest_repository(ctx: &TomlCtx<'_>) -> Vec<Violation> {
24 if ctx.file.rel != ITEMS_REL || !ctx.parse.errors.is_empty() {
25 return Vec::new();
26 }
27
28 let Some(root) = manifest_repository_root(ctx.file.path) else {
29 return Vec::new();
30 };
31 let repository = ManifestRepository::new(root);
32 let spec_paths = match repository.spec_paths() {
33 Ok(paths) => paths,
34 Err(error) => return vec![violation(ctx.file.rel, 1, error.to_string())],
35 };
36 let mut violations = Vec::new();
37 let mut ids: HashMap<u32, &Path> = HashMap::new();
38 let mut referenced = HashSet::new();
39
40 for path in &spec_paths {
41 match repository.load_spec(path) {
42 Ok(manifest) => {
43 if let Some(previous) = ids.insert(manifest.spec.id, path) {
44 let rel = workspace_relative(path);
45
46 violations.push(violation(
47 &rel,
48 1,
49 format!(
50 "spec id {} duplicates {}",
51 manifest.spec.id,
52 workspace_relative(previous)
53 ),
54 ));
55 }
56 }
57 Err(error) => {
58 let rel = workspace_relative(path);
59
60 violations.push(violation(&rel, 1, error.to_string()));
61 }
62 }
63
64 collect_component_references(path, &mut referenced);
65 }
66
67 match repository.toml_paths() {
68 Ok(paths) => {
69 for path in paths {
70 if is_component(&path) {
71 let rel = workspace_relative(&path);
72 let identity = directory::canonicalize(&path).unwrap_or(path);
73
74 if !referenced.contains(&identity) {
75 violations.push(violation(
76 &rel,
77 1,
78 "manifest component is not referenced by any spec",
79 ));
80 }
81 }
82 }
83 }
84 Err(error) => violations.push(violation(ctx.file.rel, 1, error.to_string())),
85 }
86
87 violations
88}
89
90fn collect_component_references(manifest_path: &Path, referenced: &mut HashSet<PathBuf>) {
91 let Ok(source) = file::read_text(manifest_path) else {
92 return;
93 };
94 let Ok(document) = toml::from_str::<toml::Table>(&source) else {
95 return;
96 };
97 let Some(spec_dir) = manifest_path.parent() else {
98 return;
99 };
100
101 if let Some(parts) = document.get("parts").and_then(toml::Value::as_array) {
102 for part in parts.iter().filter_map(toml::Value::as_str) {
103 let path = spec_dir.join(part);
104
105 referenced.insert(directory::canonicalize(&path).unwrap_or(path));
106 }
107 }
108
109 let Some(class_dir) = spec_dir.parent() else {
110 return;
111 };
112
113 if let Some(shared) = document.get("shared").and_then(toml::Value::as_array) {
114 for include in shared.iter().filter_map(toml::Value::as_table) {
115 let Some(relative) = include.get("path").and_then(toml::Value::as_str) else {
116 continue;
117 };
118 let path = class_dir.join("shared").join(relative);
119
120 referenced.insert(directory::canonicalize(&path).unwrap_or(path));
121 }
122 }
123}
124
125fn is_component(path: &Path) -> bool {
126 path.file_name()
127 .is_some_and(|name| name != "manifest.toml" && name != "items.toml")
128 && path
129 .components()
130 .any(|component| component.as_os_str() == "specs")
131}