1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Deserialize;
4use wowlab_fs::{directory, path::Path};
5use wowlab_tidy::ConfigRule;
6
7use super::{EngineManifest, McpTool};
8use crate::infra::cargo_metadata::CargoMetadata;
9
10const REVISION_DISPLAY_LENGTH: usize = 12;
11const INLINE_CODE_INTERVAL: usize = 2;
12const GROUPS: &[&str] = &[
13 "Simulation engine",
14 "Distributed compute",
15 "Data and formats",
16 "Developer tooling",
17];
18
19#[derive(Debug)]
20pub(super) struct Catalogs {
21 pub(super) pages: Vec<Page>,
22 pub(super) package_count: usize,
23 pub(super) tidy_rule_count: usize,
24 pub(super) manifest_count: usize,
25 pub(super) mcp_tool_count: usize,
26}
27
28#[derive(Debug)]
29pub(super) struct Page {
30 pub(super) file: &'static str,
31 pub(super) title: &'static str,
32 pub(super) description: &'static str,
33 pub(super) introduction: Vec<Fragment>,
34 pub(super) sections: Vec<Section>,
35}
36
37#[derive(Debug)]
38pub(super) struct Section {
39 pub(super) id: String,
40 pub(super) title: String,
41 pub(super) count: Option<usize>,
42 pub(super) layout: &'static str,
43 pub(super) items: Vec<Item>,
44}
45
46#[derive(Debug)]
47pub(super) struct Item {
48 pub(super) id: String,
49 pub(super) label: String,
50 pub(super) label_code: bool,
51 pub(super) href: Option<String>,
52 pub(super) lines: Box<[Box<[Fragment]>]>,
53 pub(super) bullets: Box<[Box<[Fragment]>]>,
54}
55
56#[derive(Clone, Debug)]
57pub(super) struct Fragment {
58 pub(super) text: String,
59 pub(super) href: Option<String>,
60 pub(super) code: bool,
61}
62
63#[derive(Debug)]
64struct Package {
65 name: String,
66 description: String,
67 group: &'static str,
68 targets: Vec<Target>,
69}
70
71#[derive(Debug)]
72struct Target {
73 name: String,
74 directory: String,
75 kind: &'static str,
76}
77
78pub(super) struct Sources<'a> {
79 pub(super) metadata: &'a CargoMetadata,
80 pub(super) docs: &'a Path,
81 pub(super) tidy_rules: Vec<ConfigRule>,
82 pub(super) manifests: Vec<EngineManifest>,
83 pub(super) mcp_tools: Vec<McpTool>,
84 pub(super) repository: &'a str,
85 pub(super) revision: &'a str,
86}
87
88pub(super) fn build(sources: Sources<'_>) -> Catalogs {
89 let packages = packages(sources.metadata, sources.docs);
90 let package_count = packages.len();
91 let tidy_rule_count = sources.tidy_rules.len();
92 let manifest_count = sources.manifests.len();
93 let mcp_tool_count = sources.mcp_tools.len();
94 let short_revision = sources
95 .revision
96 .get(..REVISION_DISPLAY_LENGTH)
97 .unwrap_or(sources.revision);
98 let pages = vec![
99 workspace_page(
100 &packages,
101 sources.repository,
102 sources.revision,
103 short_revision,
104 ),
105 tidy_page(sources.tidy_rules),
106 manifests_page(sources.manifests, sources.repository, sources.revision),
107 mcp_page(sources.mcp_tools),
108 ];
109
110 Catalogs {
111 pages,
112 package_count,
113 tidy_rule_count,
114 manifest_count,
115 mcp_tool_count,
116 }
117}
118
119fn workspace_page(
120 packages: &[Package],
121 repository: &str,
122 revision: &str,
123 short_revision: &str,
124) -> Page {
125 let count = packages.len();
126 let mut sections = vec![Section {
127 id: "generated-catalogs".to_owned(),
128 title: "Generated catalogs".to_owned(),
129 count: None,
130 layout: "table",
131 items: vec![
132 linked_item(
133 "Tidy rules",
134 "/tidy-rules",
135 "The live registry of all configured workspace lint rules.",
136 ),
137 linked_item(
138 "Engine manifests",
139 "/engine-manifests",
140 "Implemented specialization manifests and their source definitions.",
141 ),
142 linked_item(
143 "MCP tools",
144 "/mcp-tools",
145 "Tools exposed by the Sentinel MCP server in workflow order.",
146 ),
147 ],
148 }];
149
150 sections.extend(GROUPS.iter().map(|group| {
151 Section {
152 id: slug(group),
153 title: (*group).to_owned(),
154 count: None,
155 layout: "table",
156 items: packages
157 .iter()
158 .filter(|package| package.group == *group)
159 .map(package_item)
160 .collect(),
161 }
162 }));
163
164 Page {
165 file: "index.html",
166 title: "WoW Lab Rust documentation",
167 description: "Private and public API documentation for the WoW Lab Rust workspace.",
168 introduction: vec![
169 Fragment::text(format!(
170 "Private and public API documentation for {count} workspace packages, generated from "
171 )),
172 Fragment::code_link(short_revision, format!("{repository}/commit/{revision}")),
173 Fragment::text(". Use the crate search above or browse by subsystem below."),
174 ],
175 sections,
176 }
177}
178
179fn tidy_page(rules: Vec<ConfigRule>) -> Page {
180 let count = rules.len();
181 let mut categories = BTreeMap::<String, Vec<ConfigRule>>::new();
182
183 for mut rule in rules {
184 let category = std::mem::take(&mut rule.category);
185
186 categories.entry(category).or_default().push(rule);
187 }
188
189 let sections = categories
190 .into_iter()
191 .map(|(category, mut rules)| {
192 rules.sort_by(|left, right| left.name.cmp(&right.name));
193
194 Section {
195 id: slug(&category),
196 title: title_case(&category),
197 count: Some(rules.len()),
198 layout: "table",
199 items: rules
200 .into_iter()
201 .map(|rule| Item {
202 id: String::new(),
203 label: rule.name,
204 label_code: true,
205 href: None,
206 lines: vec![
207 inline_code(&rule.description),
208 vec![Fragment::text(format!(
209 "Severity: {}{}",
210 title_case(&rule.severity),
211 if rule.fixable { " · Auto-fixable" } else { "" }
212 ))]
213 .into_boxed_slice(),
214 ]
215 .into_boxed_slice(),
216 bullets: Box::default(),
217 })
218 .collect(),
219 }
220 })
221 .collect();
222
223 Page {
224 file: "tidy-rules.html",
225 title: "Tidy rules",
226 description: "Every rule registered in the WoW Lab tidy workspace linter.",
227 introduction: vec![Fragment::text(format!(
228 "{count} rules loaded from the same runtime registry and configuration used by cargo tidy."
229 ))],
230 sections,
231 }
232}
233
234fn manifests_page(manifests: Vec<EngineManifest>, repository: &str, revision: &str) -> Page {
235 let count = manifests.len();
236 let mut classes = BTreeMap::<String, Vec<EngineManifest>>::new();
237
238 for mut manifest in manifests {
239 let class_name = std::mem::take(&mut manifest.class_name);
240
241 classes.entry(class_name).or_default().push(manifest);
242 }
243
244 let sections = classes
245 .into_iter()
246 .map(|(class_name, mut manifests)| {
247 manifests.sort_by(|left, right| left.spec_name.cmp(&right.spec_name));
248
249 Section {
250 id: slug(&class_name),
251 title: title_case(&class_name),
252 count: None,
253 layout: "table",
254 items: manifests
255 .into_iter()
256 .map(|manifest| manifest_item(&manifest, repository, revision))
257 .collect(),
258 }
259 })
260 .collect();
261
262 Page {
263 file: "engine-manifests.html",
264 title: "Engine manifests",
265 description: "Implemented WoW Lab engine specialization manifests.",
266 introduction: vec![Fragment::text(format!(
267 "{count} specialization manifests generated from the canonical engine manifest tree."
268 ))],
269 sections,
270 }
271}
272
273fn mcp_page(mut tools: Vec<McpTool>) -> Page {
274 let count = tools.len();
275
276 tools.sort_by_key(|tool| tool.order);
277 let items = tools
278 .into_iter()
279 .map(|tool| Item {
280 id: format!("tool-{}", slug(&tool.name)),
281 label: format!("{}. {}", tool.order, tool.name),
282 label_code: false,
283 href: None,
284 lines: vec![inline_code(&tool.summary)].into_boxed_slice(),
285 bullets: tool.tips.iter().map(|tip| inline_code(tip)).collect(),
286 })
287 .collect();
288
289 Page {
290 file: "mcp-tools.html",
291 title: "MCP tools",
292 description: "Tools exposed by the WoW Lab MCP server.",
293 introduction: vec![Fragment::text(format!(
294 "{count} tools loaded from Sentinel's runtime inventory in recommended workflow order."
295 ))],
296 sections: vec![Section {
297 id: "tool-inventory".to_owned(),
298 title: "Tool inventory".to_owned(),
299 count: Some(count),
300 layout: "methods",
301 items,
302 }],
303 }
304}
305
306fn packages(metadata: &CargoMetadata, docs: &Path) -> Vec<Package> {
307 let members = metadata
308 .workspace_members
309 .iter()
310 .map(AsRef::as_ref)
311 .collect::<BTreeSet<_>>();
312 let mut packages = metadata
313 .packages
314 .iter()
315 .filter(|package| members.contains(package.id.as_str()))
316 .filter_map(|package| {
317 let mut targets = package
318 .targets
319 .iter()
320 .filter(|target| target.doc)
321 .filter_map(|target| {
322 let directory_name = target.name.replace('-', "_");
323 let index = docs.join(&directory_name).join("index.html");
324
325 directory::inspect(&index)
326 .ok()
327 .flatten()
328 .filter(|entry| entry.kind() == directory::EntryKind::File)?;
329
330 Some(Target {
331 name: target.name.clone(),
332 directory: directory_name,
333 kind: if target.kind.iter().any(|kind| kind.as_ref() == "bin") {
334 "binary"
335 } else {
336 "library"
337 },
338 })
339 })
340 .collect::<Vec<_>>();
341
342 targets.sort_by(|left, right| left.directory.cmp(&right.directory));
343 targets.dedup_by(|left, right| left.directory == right.directory);
344
345 (!targets.is_empty()).then(|| Package {
346 name: package.name.clone(),
347 description: package
348 .description
349 .clone()
350 .unwrap_or_else(|| "Workspace crate.".to_owned()),
351 group: package_group(&package.name),
352 targets,
353 })
354 })
355 .collect::<Vec<_>>();
356
357 packages.sort_by(|left, right| left.name.cmp(&right.name));
358
359 packages
360}
361
362fn package_item(package: &Package) -> Item {
364 if let [target] = package.targets.as_slice() {
365 return Item {
366 id: String::new(),
367 label: package.name.clone(),
368 label_code: true,
369 href: Some(format!("/{}/", target.directory)),
370 lines: vec![vec![Fragment::text(&package.description)].into_boxed_slice()]
371 .into_boxed_slice(),
372 bullets: Box::default(),
373 };
374 }
375
376 let multiple_targets = package.targets.len() > 1;
377 let mut target_line = Vec::new();
378
379 for (index, target) in package.targets.iter().enumerate() {
380 if index > 0 {
381 target_line.push(Fragment::text(" · "));
382 }
383
384 target_line.push(Fragment::code_link(
385 &target.name,
386 format!("/{}/", target.directory),
387 ));
388
389 if multiple_targets {
390 target_line.push(Fragment::text(format!(" ({})", target.kind)));
391 }
392 }
393
394 Item {
395 id: String::new(),
396 label: package.name.clone(),
397 label_code: true,
398 href: None,
399 lines: vec![
400 vec![Fragment::text(&package.description)].into_boxed_slice(),
401 target_line.into_boxed_slice(),
402 ]
403 .into_boxed_slice(),
404 bullets: Box::default(),
405 }
406}
407
408fn manifest_item(manifest: &EngineManifest, repository: &str, revision: &str) -> Item {
409 let counts = [
410 plural(manifest.part_count, "part"),
411 plural(manifest.spell_count, "spell"),
412 plural(manifest.aura_count, "aura"),
413 plural(manifest.auto_attack_count, "auto attack"),
414 ]
415 .join(" · ");
416
417 Item {
418 id: String::new(),
419 label: title_case(&manifest.spec_name),
420 label_code: true,
421 href: None,
422 lines: vec![
423 vec![
424 Fragment::text(format!("Spec ID {} · {counts} · ", manifest.id)),
425 Fragment::link(
426 "source manifest",
427 format!("{repository}/blob/{revision}/{}", manifest.path),
428 ),
429 ]
430 .into_boxed_slice(),
431 ]
432 .into_boxed_slice(),
433 bullets: Box::default(),
434 }
435}
436
437fn linked_item(label: &str, href: &str, description: &str) -> Item {
438 Item {
439 id: String::new(),
440 label: label.to_owned(),
441 label_code: false,
442 href: Some(href.to_owned()),
443 lines: vec![vec![Fragment::text(description)].into_boxed_slice()].into_boxed_slice(),
444 bullets: Box::default(),
445 }
446}
447
448fn package_group(name: &str) -> &'static str {
449 if name.starts_with("wowlab-engine") || matches!(name, "wowlab-buffer-contract" | "wowlab-wasm")
450 {
451 return "Simulation engine";
452 }
453
454 if matches!(
455 name,
456 "wowlab-centrifuge"
457 | "wowlab-node"
458 | "wowlab-node-gui"
459 | "wowlab-node-headless"
460 | "wowlab-sentinel"
461 | "wowlab-supabase"
462 ) {
463 return "Distributed compute";
464 }
465
466 if matches!(
467 name,
468 "wowlab-analytics"
469 | "wowlab-common"
470 | "wowlab-fs"
471 | "wowlab-loadout"
472 | "wowlab-manifest-schema"
473 | "wowlab-parsers"
474 | "wowlab-types"
475 ) {
476 return "Data and formats";
477 }
478
479 "Developer tooling"
480}
481
482fn title_case(value: &str) -> String {
483 value
484 .split(['-', '_'])
485 .filter(|part| !part.is_empty())
486 .map(|part| {
487 let mut chars = part.chars();
488
489 chars.next().map_or_else(String::new, |first| {
490 first.to_uppercase().collect::<String>() + chars.as_str()
491 })
492 })
493 .collect::<Vec<_>>()
494 .join(" ")
495 .replace("Mcp", "MCP")
496 .replace("Wasm", "WASM")
497 .replace("Cli", "CLI")
498}
499
500fn slug(value: &str) -> String {
501 value.to_lowercase().replace([' ', '_'], "-")
502}
503
504fn plural(count: usize, noun: &str) -> String {
505 format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
506}
507
508pub(super) fn inline_code(value: &str) -> Box<[Fragment]> {
509 value
510 .split('`')
511 .enumerate()
512 .filter(|(_, text)| !text.is_empty())
513 .map(|(index, text)| {
514 if index % INLINE_CODE_INTERVAL == 0 {
515 Fragment::text(text)
516 } else {
517 Fragment::code(text)
518 }
519 })
520 .collect()
521}
522
523impl Fragment {
524 fn text(text: impl Into<String>) -> Self {
525 Self {
526 text: text.into(),
527 href: None,
528 code: false,
529 }
530 }
531
532 fn link(text: impl Into<String>, href: impl Into<String>) -> Self {
533 Self {
534 text: text.into(),
535 href: Some(href.into()),
536 code: false,
537 }
538 }
539
540 fn code(text: impl Into<String>) -> Self {
541 Self {
542 text: text.into(),
543 href: None,
544 code: true,
545 }
546 }
547
548 fn code_link(text: impl Into<String>, href: impl Into<String>) -> Self {
549 Self {
550 text: text.into(),
551 href: Some(href.into()),
552 code: true,
553 }
554 }
555}
556
557#[derive(Debug, Deserialize)]
558pub(super) struct ManifestSource {
559 #[serde(default)]
560 pub(super) parts: Box<[Box<str>]>,
561 pub(super) spec: ManifestSpec,
562 #[serde(default)]
563 pub(super) auras: BTreeMap<String, toml::Value>,
564 #[serde(default)]
565 pub(super) spells: BTreeMap<String, toml::Value>,
566 #[serde(default)]
567 pub(super) auto_attacks: BTreeMap<String, toml::Value>,
568}
569
570#[derive(Debug, Deserialize)]
571pub(super) struct ManifestSpec {
572 pub(super) id: u32,
573}