Skip to main content

wowlab_docgen_cli/hosted_rustdoc/
catalog.rs

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
362// #t(fn: rust_alloc_in_loop) each target contributes owned link and kind fragments to the rendered catalog
363fn 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}