Skip to main content

wowlab_manifest_schema/
repository_composition.rs

1// #t(file: rust_clone_in_loop) Composition owns bounded keys and values for diagnostics and patches.
2
3//! TOML component parsing, ordered composition, and shared-section patching.
4
5use indexmap::IndexSet;
6use serde::Deserialize;
7use wowlab_fs::{
8    containment, file,
9    path::{Component, Path, PathBuf},
10    walk,
11};
12
13use crate::{CURRENT_SCHEMA_VERSION, ManifestLoadError};
14
15const SHARED_DIR: &str = "shared";
16const TABLE_SECTIONS: &[&str] = &[
17    "auras",
18    "spells",
19    "auto_attacks",
20    "talents",
21    "set_bonuses",
22    "talent_companion_auras",
23    "hero_talents",
24    "effects",
25    "content_effects",
26    "reported_spells",
27];
28const ARRAY_SECTIONS: &[&str] = &[
29    "spell_groups",
30    "impact_procs",
31    "talent_gated_aura_effects",
32    "masked_passive_effects",
33    "passive_effect_overrides",
34];
35
36pub(super) fn compose_spec_document(path: &Path) -> Result<toml::Table, ManifestLoadError> {
37    let mut document = read_table(path)?;
38
39    validate_schema_version(&document, path)?;
40    let parts = parse_paths(&mut document, "parts", path)?;
41    let shared = parse_shared(&mut document, path)?;
42    let mut included = IndexSet::with_capacity(parts.len() + shared.len());
43
44    load_private_parts(&mut document, path, parts, &mut included)?;
45    load_shared_components(&mut document, path, shared, &mut included)?;
46
47    Ok(document)
48}
49
50pub(super) fn validate_schema_version(
51    document: &toml::Table,
52    path: &Path,
53) -> Result<(), ManifestLoadError> {
54    let Some(version) = document.get("schema_version") else {
55        return Err(ManifestLoadError::MissingSchemaVersion {
56            path: path.to_path_buf(),
57        });
58    };
59    let Some(found) = version.as_integer() else {
60        return Ok(());
61    };
62
63    if found != i64::from(CURRENT_SCHEMA_VERSION) {
64        return Err(ManifestLoadError::UnsupportedSchemaVersion {
65            path: path.to_path_buf(),
66            found,
67            expected: CURRENT_SCHEMA_VERSION,
68        });
69    }
70
71    Ok(())
72}
73
74fn load_private_parts(
75    document: &mut toml::Table,
76    manifest_path: &Path,
77    parts: Vec<PathBuf>,
78    included: &mut IndexSet<PathBuf>,
79) -> Result<(), ManifestLoadError> {
80    let spec_dir = manifest_path
81        .parent()
82        .ok_or_else(|| ManifestLoadError::InvalidSpecPath {
83            path: manifest_path.to_path_buf(),
84        })?;
85
86    for part in parts {
87        validate_component_path(&part)?;
88        reject_duplicate_component(included, manifest_path, &part)?;
89        let part_path = canonical_component_path(spec_dir, &part)?;
90        let component = read_component(&part_path)?;
91
92        merge_component(document, component, &part_path)?;
93    }
94
95    Ok(())
96}
97
98fn load_shared_components(
99    document: &mut toml::Table,
100    manifest_path: &Path,
101    shared: Vec<SharedInclude>,
102    included: &mut IndexSet<PathBuf>,
103) -> Result<(), ManifestLoadError> {
104    let class_dir = manifest_path
105        .parent()
106        .and_then(Path::parent)
107        .ok_or_else(|| ManifestLoadError::InvalidSpecPath {
108            path: manifest_path.to_path_buf(),
109        })?;
110
111    for include in shared {
112        validate_component_path(&include.path)?;
113        let identity = PathBuf::from(SHARED_DIR).join(&include.path);
114
115        reject_duplicate_component(included, manifest_path, &identity)?;
116        let shared_dir = class_dir.join(SHARED_DIR);
117        let shared_path = canonical_component_path(&shared_dir, &include.path)?;
118        let component = read_component(&shared_path)?;
119
120        apply_shared_component(document, component, include, &shared_path)?;
121    }
122
123    Ok(())
124}
125
126#[derive(Debug, Deserialize)]
127#[serde(deny_unknown_fields)]
128struct SharedInclude {
129    path: PathBuf,
130    #[serde(default)]
131    before: OrderedSections,
132    #[serde(default)]
133    append: AppendSections,
134    #[serde(default)]
135    patch: PatchSections,
136}
137
138#[derive(Debug, Default, Deserialize)]
139#[serde(deny_unknown_fields)]
140#[expect(
141    clippy::struct_excessive_bools,
142    reason = "fields mirror independent append switches in the flat TOML include schema"
143)]
144struct AppendSections {
145    #[serde(default)]
146    auras: bool,
147    #[serde(default)]
148    spells: bool,
149    #[serde(default)]
150    auto_attacks: bool,
151    #[serde(default)]
152    hero_talents: bool,
153    #[serde(default)]
154    effects: bool,
155}
156
157impl AppendSections {
158    fn configured_sections(&self) -> impl Iterator<Item = &'static str> {
159        [
160            ("auras", self.auras),
161            ("spells", self.spells),
162            ("auto_attacks", self.auto_attacks),
163            ("hero_talents", self.hero_talents),
164            ("effects", self.effects),
165        ]
166        .into_iter()
167        .filter_map(|(section, configured)| configured.then_some(section))
168    }
169
170    fn contains(&self, section: &str) -> bool {
171        match section {
172            "auras" => self.auras,
173            "spells" => self.spells,
174            "auto_attacks" => self.auto_attacks,
175            "hero_talents" => self.hero_talents,
176            "effects" => self.effects,
177            _ => false,
178        }
179    }
180}
181
182#[derive(Debug, Default, Deserialize)]
183#[serde(deny_unknown_fields)]
184struct OrderedSections {
185    auras: Option<String>,
186    spells: Option<String>,
187    auto_attacks: Option<String>,
188    hero_talents: Option<String>,
189    effects: Option<String>,
190}
191
192impl OrderedSections {
193    fn take(&mut self, section: &str) -> Option<String> {
194        match section {
195            "auras" => self.auras.take(),
196            "spells" => self.spells.take(),
197            "auto_attacks" => self.auto_attacks.take(),
198            "hero_talents" => self.hero_talents.take(),
199            "effects" => self.effects.take(),
200            _ => None,
201        }
202    }
203
204    fn configured_sections(&self) -> impl Iterator<Item = &'static str> {
205        [
206            ("auras", self.auras.is_some()),
207            ("spells", self.spells.is_some()),
208            ("auto_attacks", self.auto_attacks.is_some()),
209            ("hero_talents", self.hero_talents.is_some()),
210            ("effects", self.effects.is_some()),
211        ]
212        .into_iter()
213        .filter_map(|(section, configured)| configured.then_some(section))
214    }
215}
216
217#[derive(Debug, Default, Deserialize)]
218#[serde(deny_unknown_fields)]
219struct PatchSections {
220    #[serde(default)]
221    auras: toml::Table,
222    #[serde(default)]
223    spells: toml::Table,
224}
225
226impl PatchSections {
227    fn take(&mut self, section: &str) -> toml::Table {
228        match section {
229            "auras" => std::mem::take(&mut self.auras),
230            "spells" => std::mem::take(&mut self.spells),
231            _ => toml::Table::new(),
232        }
233    }
234
235    fn configured_sections(&self) -> impl Iterator<Item = &'static str> {
236        [
237            ("auras", !self.auras.is_empty()),
238            ("spells", !self.spells.is_empty()),
239        ]
240        .into_iter()
241        .filter_map(|(section, configured)| configured.then_some(section))
242    }
243}
244
245pub(super) fn toml_paths_below(root: &Path) -> Result<Vec<PathBuf>, ManifestLoadError> {
246    let mut paths =
247        walk::repository_files(root).map_err(|source| ManifestLoadError::WalkDirectory {
248            path: root.to_path_buf(),
249            source,
250        })?;
251
252    paths.retain(|path| {
253        path.extension()
254            .is_some_and(|extension| extension == "toml")
255    });
256
257    Ok(paths)
258}
259
260pub(super) fn is_shared_path(path: &Path, specs_root: &Path) -> bool {
261    containment::relative_to(specs_root, path).is_ok_and(|relative| {
262        relative
263            .components()
264            .any(|component| component.as_os_str() == SHARED_DIR)
265    })
266}
267
268fn read_source(path: &Path) -> Result<String, ManifestLoadError> {
269    file::read_text(path).map_err(|source| ManifestLoadError::ReadFile {
270        path: path.to_path_buf(),
271        source,
272    })
273}
274
275pub(super) fn read_table(path: &Path) -> Result<toml::Table, ManifestLoadError> {
276    let source = read_source(path)?;
277
278    toml::from_str(&source).map_err(|source| ManifestLoadError::Parse {
279        path: path.to_path_buf(),
280        source,
281    })
282}
283
284fn parse_paths(
285    document: &mut toml::Table,
286    field: &str,
287    path: &Path,
288) -> Result<Vec<PathBuf>, ManifestLoadError> {
289    let Some(value) = document.remove(field) else {
290        return Ok(Vec::new());
291    };
292
293    value.try_into().map_err(|source| ManifestLoadError::Parse {
294        path: path.to_path_buf(),
295        source,
296    })
297}
298
299fn parse_shared(
300    document: &mut toml::Table,
301    path: &Path,
302) -> Result<Vec<SharedInclude>, ManifestLoadError> {
303    let Some(value) = document.remove("shared") else {
304        return Ok(Vec::new());
305    };
306
307    value.try_into().map_err(|source| ManifestLoadError::Parse {
308        path: path.to_path_buf(),
309        source,
310    })
311}
312
313fn validate_component_path(path: &Path) -> Result<(), ManifestLoadError> {
314    let valid_extension = path
315        .extension()
316        .is_some_and(|extension| extension == "toml");
317    let valid_components = path
318        .components()
319        .all(|component| matches!(component, Component::Normal(_) | Component::Current));
320
321    if path.as_os_str().is_empty() || !valid_extension || !valid_components {
322        return Err(ManifestLoadError::InvalidComponentPath {
323            path: path.to_path_buf(),
324        });
325    }
326
327    Ok(())
328}
329
330fn canonical_component_path(base: &Path, relative: &Path) -> Result<PathBuf, ManifestLoadError> {
331    let target = base.join(relative);
332
333    containment::resolve_existing(base, relative).map_err(|source| {
334        if let Some(outside) = source.outside_root().cloned() {
335            ManifestLoadError::PathOutsideRepository {
336                path: outside.path().to_path_buf(),
337                root: outside.root().to_path_buf(),
338                source: outside,
339            }
340        } else {
341            ManifestLoadError::ResolveComponent {
342                path: target,
343                root: base.to_path_buf(),
344                source,
345            }
346        }
347    })
348}
349
350fn reject_duplicate_component(
351    included: &mut IndexSet<PathBuf>,
352    manifest: &Path,
353    path: &Path,
354) -> Result<(), ManifestLoadError> {
355    if included.insert(path.to_path_buf()) {
356        return Ok(());
357    }
358
359    Err(ManifestLoadError::DuplicateComponent {
360        manifest: manifest.to_path_buf(),
361        path: path.to_path_buf(),
362    })
363}
364
365pub(super) fn read_component(path: &Path) -> Result<toml::Table, ManifestLoadError> {
366    let component = read_table(path)?;
367
368    if component.is_empty() {
369        return Err(ManifestLoadError::EmptyComponent {
370            path: path.to_path_buf(),
371        });
372    }
373
374    for section in component.keys() {
375        if !TABLE_SECTIONS.contains(&section.as_str())
376            && !ARRAY_SECTIONS.contains(&section.as_str())
377        {
378            return Err(ManifestLoadError::UnsupportedSection {
379                source_path: path.to_path_buf(),
380                section: section.clone(),
381            });
382        }
383    }
384
385    Ok(component)
386}
387
388fn merge_component(
389    document: &mut toml::Table,
390    component: toml::Table,
391    source_path: &Path,
392) -> Result<(), ManifestLoadError> {
393    for (section, value) in component {
394        if TABLE_SECTIONS.contains(&section.as_str()) {
395            let source = value.as_table().cloned().ok_or_else(|| {
396                ManifestLoadError::InvalidTargetSection {
397                    source_path: source_path.to_path_buf(),
398                    section: section.clone(),
399                }
400            })?;
401
402            merge_table_section(document, &section, source, None, source_path)?;
403        } else {
404            merge_array_section(document, &section, &value, source_path)?;
405        }
406    }
407
408    Ok(())
409}
410
411fn apply_shared_component(
412    document: &mut toml::Table,
413    component: toml::Table,
414    mut include: SharedInclude,
415    shared_path: &Path,
416) -> Result<(), ManifestLoadError> {
417    let present: IndexSet<String> = component.keys().cloned().collect();
418
419    for section in include
420        .before
421        .configured_sections()
422        .chain(include.append.configured_sections())
423        .chain(include.patch.configured_sections())
424    {
425        if !present.contains(section) {
426            return Err(ManifestLoadError::ConfigurationWithoutSection {
427                shared: shared_path.to_path_buf(),
428                section,
429            });
430        }
431    }
432
433    for section in include.before.configured_sections() {
434        if include.append.contains(section) {
435            return Err(ManifestLoadError::ConflictingSectionOrder {
436                shared: shared_path.to_path_buf(),
437                section,
438            });
439        }
440    }
441
442    for (section, value) in component {
443        if TABLE_SECTIONS.contains(&section.as_str()) {
444            let mut source = value.as_table().cloned().ok_or_else(|| {
445                ManifestLoadError::InvalidTargetSection {
446                    source_path: shared_path.to_path_buf(),
447                    section: section.clone(),
448                }
449            })?;
450            let patch = include.patch.take(&section);
451
452            if !patch.is_empty() {
453                patch_entries(&mut source, &section, &patch, shared_path)?;
454            }
455
456            let before = include.before.take(&section);
457
458            merge_table_section(document, &section, source, before.as_deref(), shared_path)?;
459        } else {
460            merge_array_section(document, &section, &value, shared_path)?;
461        }
462    }
463
464    Ok(())
465}
466
467fn merge_table_section(
468    document: &mut toml::Table,
469    section: &str,
470    source: toml::Table,
471    before: Option<&str>,
472    source_path: &Path,
473) -> Result<(), ManifestLoadError> {
474    let target = document
475        .entry(section)
476        .or_insert_with(|| toml::Value::Table(toml::Table::new()))
477        .as_table_mut()
478        .ok_or_else(|| ManifestLoadError::InvalidTargetSection {
479            source_path: source_path.to_path_buf(),
480            section: section.to_owned(),
481        })?;
482
483    if let Some(insertion_point) = before {
484        if !target.contains_key(insertion_point) {
485            return Err(ManifestLoadError::MissingInsertionPoint {
486                shared: source_path.to_path_buf(),
487                section: ordered_section_name(section),
488                before: insertion_point.to_owned(),
489            });
490        }
491    }
492
493    for key in source.keys() {
494        if target.contains_key(key) {
495            return Err(ManifestLoadError::DuplicateKey {
496                source_path: source_path.to_path_buf(),
497                section: section.to_owned(),
498                key: key.clone(),
499            });
500        }
501    }
502
503    insert_entries(target, source.into_iter().collect(), before);
504
505    Ok(())
506}
507
508fn merge_array_section(
509    document: &mut toml::Table,
510    section: &str,
511    source: &toml::Value,
512    source_path: &Path,
513) -> Result<(), ManifestLoadError> {
514    let source =
515        source
516            .as_array()
517            .cloned()
518            .ok_or_else(|| ManifestLoadError::InvalidArraySection {
519                source_path: source_path.to_path_buf(),
520                section: section.to_owned(),
521            })?;
522    let target = document
523        .entry(section)
524        .or_insert_with(|| toml::Value::Array(Vec::new()))
525        .as_array_mut()
526        .ok_or_else(|| ManifestLoadError::InvalidArraySection {
527            source_path: source_path.to_path_buf(),
528            section: section.to_owned(),
529        })?;
530
531    target.extend(source);
532
533    Ok(())
534}
535
536fn patch_entries(
537    source: &mut toml::Table,
538    section: &str,
539    patch: &toml::Table,
540    shared_path: &Path,
541) -> Result<(), ManifestLoadError> {
542    let section = ordered_section_name(section);
543
544    for (key, value) in source {
545        let table = value
546            .as_table_mut()
547            .ok_or_else(|| ManifestLoadError::InvalidPatchTarget {
548                shared: shared_path.to_path_buf(),
549                section,
550                key: key.clone(),
551            })?;
552
553        table.extend(
554            patch
555                .iter()
556                .map(|(key, value)| (key.clone(), value.clone())),
557        );
558    }
559
560    Ok(())
561}
562
563// #t(fn: rust_const_fn_candidate) Matching string slices is not const-stable.
564fn ordered_section_name(section: &str) -> &'static str {
565    match section {
566        "auras" => "auras",
567        "spells" => "spells",
568        "auto_attacks" => "auto_attacks",
569        "hero_talents" => "hero_talents",
570        "effects" => "effects",
571        _ => "unknown",
572    }
573}
574
575fn insert_entries(
576    target: &mut toml::Table,
577    entries: Vec<(String, toml::Value)>,
578    before: Option<&str>,
579) {
580    let previous = std::mem::take(target);
581    let mut pending = Some(entries);
582
583    for (key, value) in previous {
584        if before == Some(key.as_str()) {
585            if let Some(entries) = pending.take() {
586                target.extend(entries);
587            }
588        }
589
590        target.insert(key, value);
591    }
592
593    if let Some(entries) = pending {
594        target.extend(entries);
595    }
596}
597
598pub(super) fn deserialize_section<T>(
599    value: Option<toml::Value>,
600    path: &Path,
601) -> Result<T, ManifestLoadError>
602where
603    T: Default + for<'de> Deserialize<'de>,
604{
605    let Some(value) = value else {
606        return Ok(T::default());
607    };
608
609    value.try_into().map_err(|source| ManifestLoadError::Parse {
610        path: path.to_path_buf(),
611        source,
612    })
613}