1use std::collections::BTreeMap;
7
8use indexmap::IndexMap;
9use wowlab_fs::{
10 containment::{self, OutsideRoot},
11 path::{Component, Path, PathBuf},
12};
13
14use crate::{
15 EffectRef, ItemsManifest, Manifest,
16 repository_composition::{
17 compose_spec_document, deserialize_section, is_shared_path, read_component, read_table,
18 toml_paths_below, validate_schema_version,
19 },
20};
21
22const ENTRY_FILE: &str = "manifest.toml";
23const ITEMS_FILE: &str = "items.toml";
24const SPECS_DIR: &str = "specs";
25
26#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum ManifestLoadError {
30 #[error("failed to walk manifest directory {path}: {source}")]
31 WalkDirectory {
32 path: PathBuf,
33 #[source]
34 source: wowlab_fs::walk::Error,
35 },
36 #[error("failed to read manifest {path}: {source}")]
37 ReadFile {
38 path: PathBuf,
39 #[source]
40 source: wowlab_fs::error::Error,
41 },
42 #[error("failed to parse manifest {path}: {source}")]
43 Parse {
44 path: PathBuf,
45 #[source]
46 source: toml::de::Error,
47 },
48 #[error("root manifest {path} does not declare schema_version")]
49 MissingSchemaVersion { path: PathBuf },
50 #[error("root manifest {path} uses schema_version {found}, but this build supports {expected}")]
51 UnsupportedSchemaVersion {
52 path: PathBuf,
53 found: i64,
54 expected: u32,
55 },
56 #[error("manifest repository has no {ENTRY_FILE} entries below {path}")]
57 NoSpecs { path: PathBuf },
58 #[error("manifest component {path} is empty")]
59 EmptyComponent { path: PathBuf },
60 #[error("component path '{path}' must be a relative .toml path without parent traversal")]
61 InvalidComponentPath { path: PathBuf },
62 #[error("spec entry path {path} must be specs/<class>/<specialization>/{ENTRY_FILE}")]
63 InvalidSpecPath { path: PathBuf },
64 #[error("manifest path {path} is outside repository section {root}")]
65 PathOutsideRepository {
66 path: PathBuf,
67 root: PathBuf,
68 #[source]
69 source: OutsideRoot,
70 },
71 #[error("failed to resolve manifest component {path} within {root}: {source}")]
72 ResolveComponent {
73 path: PathBuf,
74 root: PathBuf,
75 #[source]
76 source: containment::ResolveError,
77 },
78 #[error("manifest {manifest} includes component '{path}' more than once")]
79 DuplicateComponent { manifest: PathBuf, path: PathBuf },
80 #[error("manifest component {source_path} contains unsupported top-level section '{section}'")]
81 UnsupportedSection {
82 source_path: PathBuf,
83 section: String,
84 },
85 #[error("manifest component {source_path} [{section}] duplicates key {key}")]
86 DuplicateKey {
87 source_path: PathBuf,
88 section: String,
89 key: String,
90 },
91 #[error("manifest component {source_path} duplicates array section [[{section}]]")]
92 InvalidArraySection {
93 source_path: PathBuf,
94 section: String,
95 },
96 #[error("shared manifest {shared} [{section}] cannot be placed before missing key {before}")]
97 MissingInsertionPoint {
98 shared: PathBuf,
99 section: &'static str,
100 before: String,
101 },
102 #[error(
103 "include of {shared} configures [{section}] but that section is absent from the shared manifest"
104 )]
105 ConfigurationWithoutSection {
106 shared: PathBuf,
107 section: &'static str,
108 },
109 #[error("include of {shared} configures both before and append ordering for [{section}]")]
110 ConflictingSectionOrder {
111 shared: PathBuf,
112 section: &'static str,
113 },
114 #[error("manifest section [{section}] is not a table while composing {source_path}")]
115 InvalidTargetSection {
116 source_path: PathBuf,
117 section: String,
118 },
119 #[error("shared manifest {shared} [{section}].{key} cannot accept a table patch")]
120 InvalidPatchTarget {
121 shared: PathBuf,
122 section: &'static str,
123 key: String,
124 },
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct SharedManifestPath {
130 path: PathBuf,
131 relative_path: PathBuf,
132}
133
134impl SharedManifestPath {
135 #[must_use]
137 pub fn path(&self) -> &Path {
138 &self.path
139 }
140
141 #[must_use]
143 pub fn relative_path(&self) -> &Path {
144 &self.relative_path
145 }
146}
147
148#[derive(Debug, Default)]
150pub struct SharedBindings {
151 pub effects: IndexMap<String, EffectRef>,
152 pub reported_spells: BTreeMap<String, u32>,
153}
154
155#[derive(Clone, Debug)]
157pub struct ManifestRepository {
158 root: PathBuf,
159}
160
161impl ManifestRepository {
162 pub fn new(root: impl Into<PathBuf>) -> Self {
164 Self { root: root.into() }
165 }
166
167 #[must_use]
169 pub fn root(&self) -> &Path {
170 &self.root
171 }
172
173 pub fn toml_paths(&self) -> Result<Vec<PathBuf>, ManifestLoadError> {
179 toml_paths_below(&self.root)
180 }
181
182 pub fn spec_paths(&self) -> Result<Vec<PathBuf>, ManifestLoadError> {
188 let root = self.root.join(SPECS_DIR);
189 let mut paths = toml_paths_below(&root)?;
190
191 paths.retain(|path| path.file_name().is_some_and(|name| name == ENTRY_FILE));
192
193 if paths.is_empty() {
194 return Err(ManifestLoadError::NoSpecs { path: root });
195 }
196
197 Ok(paths)
198 }
199
200 pub fn spec_slug(&self, path: &Path) -> Result<String, ManifestLoadError> {
206 let specs_root = self.root.join(SPECS_DIR);
207 let relative = containment::relative_to(&specs_root, path).map_err(|source| {
208 ManifestLoadError::PathOutsideRepository {
209 path: path.to_path_buf(),
210 root: specs_root,
211 source,
212 }
213 })?;
214 let mut components = relative.components();
215 let (Some(Component::Normal(class)), Some(Component::Normal(specialization))) =
216 (components.next(), components.next())
217 else {
218 return Err(ManifestLoadError::InvalidSpecPath {
219 path: path.to_path_buf(),
220 });
221 };
222
223 if components.next() != Some(Component::Normal(ENTRY_FILE.as_ref()))
224 || components.next().is_some()
225 {
226 return Err(ManifestLoadError::InvalidSpecPath {
227 path: path.to_path_buf(),
228 });
229 }
230
231 Ok(format!(
232 "{}_{}",
233 specialization.to_string_lossy(),
234 class.to_string_lossy()
235 ))
236 }
237
238 pub fn shared_paths(&self) -> Result<Vec<SharedManifestPath>, ManifestLoadError> {
244 let root = self.root.join(SPECS_DIR);
245
246 toml_paths_below(&root)?
247 .into_iter()
248 .filter(|path| is_shared_path(path, &root))
249 .map(|path| {
250 let relative_path = containment::relative_to(&root, &path)
251 .map(Path::to_path_buf)
252 .map_err(|source| ManifestLoadError::PathOutsideRepository {
253 path: path.clone(),
254 root: root.clone(),
255 source,
256 })?;
257
258 Ok(SharedManifestPath {
259 path,
260 relative_path,
261 })
262 })
263 .collect()
264 }
265
266 pub fn load_spec(&self, path: &Path) -> Result<Manifest, ManifestLoadError> {
272 self.spec_slug(path)?;
273 let document = compose_spec_document(path)?;
274
275 toml::Value::Table(document)
276 .try_into()
277 .map_err(|source| ManifestLoadError::Parse {
278 path: path.to_path_buf(),
279 source,
280 })
281 }
282
283 pub fn load_items(&self) -> Result<ItemsManifest, ManifestLoadError> {
289 let path = self.root.join(ITEMS_FILE);
290 let document = read_table(&path)?;
291
292 validate_schema_version(&document, &path)?;
293
294 toml::Value::Table(document)
295 .try_into()
296 .map_err(|source| ManifestLoadError::Parse { path, source })
297 }
298
299 pub fn load_shared_bindings(
305 &self,
306 shared_path: &SharedManifestPath,
307 ) -> Result<SharedBindings, ManifestLoadError> {
308 let mut shared = read_component(shared_path.path())?;
309 let effects = deserialize_section(shared.remove("effects"), shared_path.path())?;
310 let reported_spells =
311 deserialize_section(shared.remove("reported_spells"), shared_path.path())?;
312
313 Ok(SharedBindings {
314 effects,
315 reported_spells,
316 })
317 }
318}