Skip to main content

wowlab_manifest_schema/
repository.rs

1// #t(file: rust_public_error_enum) established repository errors expose actionable composition failure variants
2// #t(file: rust_pub_api_foreign_types) repository errors retain typed sources for diagnostic chaining
3
4//! Filesystem-backed manifest discovery and loading.
5
6use 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/// Error returned while discovering, reading, composing, or parsing manifests.
27#[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/// A shared manifest path discovered below a class-local `shared` directory.
128#[derive(Clone, Debug, Eq, PartialEq)]
129pub struct SharedManifestPath {
130    path: PathBuf,
131    relative_path: PathBuf,
132}
133
134impl SharedManifestPath {
135    /// Filesystem path used to read the shared manifest.
136    #[must_use]
137    pub fn path(&self) -> &Path {
138        &self.path
139    }
140
141    /// Path relative to the repository's `specs` directory.
142    #[must_use]
143    pub fn relative_path(&self) -> &Path {
144        &self.relative_path
145    }
146}
147
148/// Bindings a shared manifest can expose to hand-authored shared hooks.
149#[derive(Debug, Default)]
150pub struct SharedBindings {
151    pub effects: IndexMap<String, EffectRef>,
152    pub reported_spells: BTreeMap<String, u32>,
153}
154
155/// Canonical filesystem-backed source for spec, shared, and item manifests.
156#[derive(Clone, Debug)]
157pub struct ManifestRepository {
158    root: PathBuf,
159}
160
161impl ManifestRepository {
162    /// Creates a repository rooted at the directory containing `specs` and `items.toml`.
163    pub fn new(root: impl Into<PathBuf>) -> Self {
164        Self { root: root.into() }
165    }
166
167    /// Repository root.
168    #[must_use]
169    pub fn root(&self) -> &Path {
170        &self.root
171    }
172
173    /// Discovers every TOML document in the repository, sorted by path.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error when the repository tree cannot be read.
178    pub fn toml_paths(&self) -> Result<Vec<PathBuf>, ManifestLoadError> {
179        toml_paths_below(&self.root)
180    }
181
182    /// Discovers every spec entry recursively, sorted by path.
183    ///
184    /// # Errors
185    ///
186    /// Returns an error when the repository tree cannot be read or contains no spec entries.
187    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    /// Derives the public `<specialization>_<class>` slug from an entry path.
201    ///
202    /// # Errors
203    ///
204    /// Returns an error when `path` is outside the repository or is not a spec entry path.
205    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    /// Discovers every class-shared manifest recursively, sorted by path.
239    ///
240    /// # Errors
241    ///
242    /// Returns an error when the repository tree cannot be read or a path cannot be normalized.
243    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    /// Loads a spec entry, its ordered private parts, and its class-shared includes.
267    ///
268    /// # Errors
269    ///
270    /// Returns an error when any manifest cannot be read, composed, validated, or deserialized.
271    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    /// Loads the repository's item manifest.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error when the item manifest cannot be read, validated, or deserialized.
288    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    /// Loads effect and reported-spell bindings from a discovered shared manifest.
300    ///
301    /// # Errors
302    ///
303    /// Returns an error when the shared manifest cannot be read or its sections are invalid.
304    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}