1use 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(§ion.as_str())
376 && !ARRAY_SECTIONS.contains(§ion.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(§ion.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, §ion, source, None, source_path)?;
403 } else {
404 merge_array_section(document, §ion, &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(§ion.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(§ion);
451
452 if !patch.is_empty() {
453 patch_entries(&mut source, §ion, &patch, shared_path)?;
454 }
455
456 let before = include.before.take(§ion);
457
458 merge_table_section(document, §ion, source, before.as_deref(), shared_path)?;
459 } else {
460 merge_array_section(document, §ion, &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
563fn 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}