Skip to main content

aidoc_core/
generate.rs

1//! Generate stage: project an [`IndexedWorkspace`] into LLM-facing
2//! artifacts.
3//!
4//! The Preset::Publish set produces five artifact families:
5//!
6//! - `llms.txt` (top-level index, [llmstxt.org](https://llmstxt.org))
7//! - `<crate>/index.md` (narrative for each crate root)
8//! - `<crate>/<module>.md` (narrative + public-item reference per module)
9//! - `llms-full.txt` (all markdown concatenated, chunk-delimited)
10//! - `api/<crate>.json` (deterministic public-API surface)
11//!
12//! Each artifact has its own render function; [`render_all`] wires them
13//! together and returns a deterministic list of `(relative_path, body)`
14//! pairs the caller can write to disk (or diff against a checked-in
15//! tree in `--check` mode). The generate stage never touches the
16//! filesystem itself.
17
18use std::fmt::Write as _;
19
20use rustdoc_types::{Item, ItemEnum, Module, Visibility};
21use serde::Serialize;
22
23use crate::error::Result;
24use crate::error_catalog::{ErrorEntry, Snippet};
25use crate::index::{IndexedCrate, IndexedWorkspace};
26
27/// One generated artifact ready to be written to disk.
28///
29/// The `path` is relative to the base directory chosen by
30/// [`location`](Self::location). The `body` is the exact bytes to
31/// write; the renderer already appended a trailing newline where the
32/// artifact family expects one.
33#[derive(Debug, Clone)]
34pub struct Artifact {
35    /// Which base directory `path` is relative to.
36    pub location: ArtifactLocation,
37    /// Path relative to the artifact's base directory (uses forward
38    /// slashes).
39    pub path: String,
40    /// Full file contents.
41    pub body: String,
42}
43
44/// Which base directory an [`Artifact`]'s path is relative to.
45///
46/// Core `Preset::Publish` output goes into [`OutDir`](Self::OutDir).
47/// Platform overlays that need to place a manifest at the repository
48/// root (e.g. `context7.json`, `.devin/wiki.json`) use
49/// [`WorkspaceRoot`](Self::WorkspaceRoot) so callers know not to
50/// collapse them under `out_dir`.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum ArtifactLocation {
53    /// Path is relative to `Config::out_dir`
54    /// (default `<workspace>/docs/aidoc/`).
55    #[default]
56    OutDir,
57    /// Path is relative to the workspace root (the parent of the
58    /// top-level `Cargo.toml`).
59    WorkspaceRoot,
60}
61
62impl Artifact {
63    /// Convenience constructor for artifacts that land under
64    /// `Config::out_dir`.
65    pub fn in_out_dir(path: impl Into<String>, body: impl Into<String>) -> Self {
66        Self {
67            location: ArtifactLocation::OutDir,
68            path: path.into(),
69            body: body.into(),
70        }
71    }
72
73    /// Convenience constructor for artifacts that land at the
74    /// workspace root (typically platform manifests).
75    pub fn in_workspace_root(path: impl Into<String>, body: impl Into<String>) -> Self {
76        Self {
77            location: ArtifactLocation::WorkspaceRoot,
78            path: path.into(),
79            body: body.into(),
80        }
81    }
82}
83
84/// Render every artifact in the Preset::Publish set for a workspace.
85///
86/// The returned artifacts appear in a stable order: `llms.txt` first,
87/// then `<crate>/index.md` and `<crate>/<module>.md` for each crate in
88/// discovery order, then `api/<crate>.json` for each crate, and finally
89/// `llms-full.txt`. Callers that want to write only a subset can filter
90/// by `path`.
91pub fn render_all(workspace: &IndexedWorkspace, title: Option<&str>) -> Result<Vec<Artifact>> {
92    let mut artifacts = Vec::new();
93
94    artifacts.push(Artifact::in_out_dir(
95        "llms.txt",
96        render_llms_txt(workspace, title),
97    ));
98
99    for krate in &workspace.crates {
100        let slug = crate_slug(&krate.name);
101        artifacts.push(Artifact::in_out_dir(
102            format!("{slug}/index.md"),
103            render_crate_index(krate),
104        ));
105
106        for module in public_modules(krate) {
107            artifacts.push(Artifact::in_out_dir(
108                format!("{slug}/{}.md", module_slug(&module.path)),
109                render_module(krate, &module),
110            ));
111        }
112    }
113
114    for krate in &workspace.crates {
115        artifacts.push(Artifact::in_out_dir(
116            format!("api/{}.json", crate_slug(&krate.name)),
117            render_api_json(krate)?,
118        ));
119    }
120
121    artifacts.push(Artifact::in_out_dir(
122        "llms-full.txt",
123        render_llms_full(&artifacts),
124    ));
125
126    Ok(artifacts)
127}
128
129/// Append the error-catalog artifact set to `artifacts` in place.
130///
131/// Emits three families:
132///
133/// - `errors/<CODE>.md` — per-code narrative page: the message
134///   template, `help` / `url` metadata, the original doc body, and
135///   every tagged snippet grouped by its bare tags.
136/// - `errors/index.json` — deterministic list of every `ErrorEntry`
137///   for machine consumption (drift check, MCP fetch by code).
138/// - `llms-errors.txt` — top-level index of every code, mirroring
139///   `llms.txt`'s shape so LLM callers already conditioned on
140///   [llmstxt.org](https://llmstxt.org) can consume it identically.
141///
142/// If `entries` is empty the function still emits the three artifacts
143/// (with empty bodies / an empty JSON array) so `--check` can detect
144/// the transition from "some errors" to "none". This mirrors the
145/// treatment of empty `llms-full.txt`.
146pub fn render_error_catalog(entries: &[ErrorEntry], artifacts: &mut Vec<Artifact>) -> Result<()> {
147    for entry in entries {
148        artifacts.push(Artifact::in_out_dir(
149            format!("errors/{}.md", entry.code),
150            render_error_entry_md(entry),
151        ));
152    }
153
154    artifacts.push(Artifact::in_out_dir(
155        "errors/index.json",
156        render_error_index_json(entries)?,
157    ));
158
159    artifacts.push(Artifact::in_out_dir(
160        "llms-errors.txt",
161        render_llms_errors_txt(entries),
162    ));
163
164    Ok(())
165}
166
167fn render_error_entry_md(entry: &ErrorEntry) -> String {
168    let mut out = String::new();
169    writeln!(&mut out, "# {}", entry.code).unwrap();
170    out.push('\n');
171
172    if let Some(msg) = &entry.message_template {
173        writeln!(&mut out, "**Message:** `{msg}`").unwrap();
174        out.push('\n');
175    }
176    if let Some(help) = &entry.help {
177        writeln!(&mut out, "**Help:** {help}").unwrap();
178        out.push('\n');
179    }
180    if let Some(url) = &entry.url {
181        writeln!(&mut out, "**Reference:** <{url}>").unwrap();
182        out.push('\n');
183    }
184    writeln!(&mut out, "**Defined in:** `{}`", entry.item_path).unwrap();
185    out.push('\n');
186
187    if !entry.docs.trim().is_empty() {
188        writeln!(&mut out, "## Description").unwrap();
189        out.push('\n');
190        out.push_str(&entry.docs);
191        if !entry.docs.ends_with('\n') {
192            out.push('\n');
193        }
194        out.push('\n');
195    }
196
197    if !entry.snippets.is_empty() {
198        writeln!(&mut out, "## Snippets").unwrap();
199        out.push('\n');
200        for snippet in &entry.snippets {
201            let heading = snippet_heading(snippet);
202            writeln!(&mut out, "### {heading}").unwrap();
203            out.push('\n');
204            writeln!(&mut out, "```{}", snippet.lang).unwrap();
205            out.push_str(&snippet.body);
206            if !snippet.body.ends_with('\n') {
207                out.push('\n');
208            }
209            writeln!(&mut out, "```").unwrap();
210            out.push('\n');
211        }
212    }
213
214    out
215}
216
217fn snippet_heading(snippet: &Snippet) -> String {
218    if snippet.tags.is_empty() {
219        "Example".to_owned()
220    } else {
221        // Capitalise each tag for a readable heading, e.g.
222        // `fix` -> `Fix`, `runtime` -> `Runtime`.
223        snippet
224            .tags
225            .iter()
226            .map(|tag| {
227                let mut chars = tag.chars();
228                match chars.next() {
229                    Some(first) => first.to_uppercase().chain(chars).collect::<String>(),
230                    None => String::new(),
231                }
232            })
233            .collect::<Vec<_>>()
234            .join(" · ")
235    }
236}
237
238fn render_error_index_json(entries: &[ErrorEntry]) -> Result<String> {
239    // `ErrorEntry` derives `Serialize` in the error_catalog module,
240    // so we can round-trip it directly. Sorting is done at the
241    // `error_catalog::extract` layer, so callers already see stable
242    // order here.
243    let json = serde_json::to_string_pretty(entries)?;
244    Ok(format!("{json}\n"))
245}
246
247fn render_llms_errors_txt(entries: &[ErrorEntry]) -> String {
248    let mut out = String::new();
249    writeln!(&mut out, "# Error Catalog").unwrap();
250    out.push('\n');
251    if entries.is_empty() {
252        out.push_str("> No diagnostics are catalogued yet.\n\n");
253        return out;
254    }
255    writeln!(
256        &mut out,
257        "> {n} diagnostic{s} catalogued from `#[derive(miette::Diagnostic)]` items.",
258        n = entries.len(),
259        s = if entries.len() == 1 { "" } else { "s" },
260    )
261    .unwrap();
262    out.push('\n');
263
264    for entry in entries {
265        writeln!(&mut out, "## {}", entry.code).unwrap();
266        out.push('\n');
267        let summary = entry
268            .message_template
269            .as_deref()
270            .or(entry.help.as_deref())
271            .unwrap_or("(no message)");
272        writeln!(
273            &mut out,
274            "- [{code} · {path}](errors/{code}.md): {summary}",
275            code = entry.code,
276            path = entry.item_path,
277        )
278        .unwrap();
279        out.push('\n');
280    }
281
282    out
283}
284
285/// Render the top-level `llms.txt` index for a workspace.
286///
287/// The output follows the [llmstxt.org](https://llmstxt.org) structure:
288/// an H1 title, an optional blockquote summary, then one H2 section per
289/// crate whose bullet list points at the per-crate / per-module markdown
290/// documents that the narrative renderer emits.
291///
292/// The `title` argument overrides the default heading (which is the
293/// basename of the workspace root). Pass `None` to accept the default.
294pub fn render_llms_txt(workspace: &IndexedWorkspace, title: Option<&str>) -> String {
295    let mut out = String::new();
296
297    let heading = title
298        .map(str::to_owned)
299        .unwrap_or_else(|| workspace_title(workspace));
300    writeln!(&mut out, "# {heading}").unwrap();
301    out.push('\n');
302
303    if let Some(summary) = workspace_summary(workspace) {
304        writeln!(&mut out, "> {summary}").unwrap();
305        out.push('\n');
306    }
307
308    for krate in &workspace.crates {
309        writeln!(&mut out, "## {} {}", krate.name, krate.version).unwrap();
310        out.push('\n');
311
312        let crate_slug = crate_slug(&krate.name);
313        let overview_summary = krate
314            .root_module_doc
315            .as_deref()
316            .and_then(first_line)
317            .unwrap_or("(no crate-root documentation)");
318        writeln!(
319            &mut out,
320            "- [{name} overview]({slug}/index.md): {summary}",
321            name = krate.name,
322            slug = crate_slug,
323            summary = overview_summary,
324        )
325        .unwrap();
326
327        for module in public_modules(krate) {
328            let module_summary = module
329                .docs
330                .and_then(first_line)
331                .unwrap_or("(no module-level documentation)");
332            writeln!(
333                &mut out,
334                "- [{name}::{path}]({slug}/{module_slug}.md): {summary}",
335                name = krate.name,
336                path = module.path,
337                slug = crate_slug,
338                module_slug = module_slug(&module.path),
339                summary = module_summary,
340            )
341            .unwrap();
342        }
343        out.push('\n');
344    }
345
346    out
347}
348
349/// Render the per-crate `index.md` narrative document.
350///
351/// The document contains the full crate-root doc comment followed by a
352/// module list. Missing docs show a short placeholder so downstream
353/// readers can tell "no docs" from "docs deliberately empty".
354pub fn render_crate_index(krate: &IndexedCrate) -> String {
355    let mut out = String::new();
356    writeln!(&mut out, "# {} {}", krate.name, krate.version).unwrap();
357    out.push('\n');
358
359    match krate.root_module_doc.as_deref() {
360        Some(doc) => {
361            out.push_str(doc);
362            if !doc.ends_with('\n') {
363                out.push('\n');
364            }
365            out.push('\n');
366        }
367        None => {
368            out.push_str("_This crate has no crate-root documentation._\n\n");
369        }
370    }
371
372    let modules = public_modules(krate);
373    if !modules.is_empty() {
374        writeln!(&mut out, "## Modules").unwrap();
375        out.push('\n');
376        for module in &modules {
377            let module_summary = module
378                .docs
379                .and_then(first_line)
380                .unwrap_or("(no module-level documentation)");
381            writeln!(
382                &mut out,
383                "- [`{path}`]({slug}.md): {summary}",
384                path = module.path,
385                slug = module_slug(&module.path),
386                summary = module_summary,
387            )
388            .unwrap();
389        }
390        out.push('\n');
391    }
392
393    out
394}
395
396/// Render the per-module narrative document.
397///
398/// Each module document contains the module-level doc comment followed by
399/// a public-item reference organised by kind (functions, types, traits,
400/// constants, macros). Item summaries are the first non-empty line of the
401/// corresponding doc comment; items with no docs show a placeholder.
402pub fn render_module(krate: &IndexedCrate, module: &PublicModule<'_>) -> String {
403    let mut out = String::new();
404    writeln!(&mut out, "# {}::{}", krate.name, module.path).unwrap();
405    out.push('\n');
406
407    match module.docs {
408        Some(doc) => {
409            out.push_str(doc);
410            if !doc.ends_with('\n') {
411                out.push('\n');
412            }
413            out.push('\n');
414        }
415        None => {
416            out.push_str("_This module has no module-level documentation._\n\n");
417        }
418    }
419
420    let items = module_items(krate, module);
421    render_item_group(&mut out, "Functions", ItemKind::Function, &items);
422    render_item_group(&mut out, "Types", ItemKind::Type, &items);
423    render_item_group(&mut out, "Traits", ItemKind::Trait, &items);
424    render_item_group(&mut out, "Constants", ItemKind::Constant, &items);
425    render_item_group(&mut out, "Macros", ItemKind::Macro, &items);
426
427    out
428}
429
430/// Render `llms-full.txt`: every markdown artifact concatenated, with a
431/// short header before each chunk so the reader can identify boundaries.
432///
433/// This intentionally skips non-markdown artifacts (`llms.txt` itself and
434/// any future JSON payloads) — those already have their own well-known
435/// paths, and duplicating them here would only bloat the file.
436pub fn render_llms_full(artifacts: &[Artifact]) -> String {
437    let mut out = String::new();
438    for artifact in artifacts {
439        if !artifact.path.ends_with(".md") {
440            continue;
441        }
442        writeln!(&mut out, "<!-- {} -->", artifact.path).unwrap();
443        out.push_str(&artifact.body);
444        if !artifact.body.ends_with('\n') {
445            out.push('\n');
446        }
447        out.push('\n');
448    }
449    out
450}
451
452/// Render a [Context7](https://context7.com) manifest (`context7.json`)
453/// for a workspace.
454///
455/// Emits the minimum useful field set: `$schema`, `projectTitle`,
456/// `description` (first non-empty line of the first crate's root doc,
457/// truncated to Context7's 10-200 character range), and `folders`
458/// (pointing at cargo-aidoc's default output tree). Callers place the
459/// returned body at the workspace root; the [`crate::platform`]
460/// dispatcher wires this up when [`crate::Platform::Context7`] is
461/// selected.
462pub fn render_context7_manifest(workspace: &IndexedWorkspace) -> String {
463    let manifest = Context7Manifest {
464        schema: "https://context7.com/schema/context7.json",
465        project_title: workspace_title(workspace),
466        description: workspace_summary(workspace).and_then(clamp_description),
467        folders: vec!["docs/aidoc".to_owned()],
468    };
469    let json = serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| String::from("{}"));
470    format!("{json}\n")
471}
472
473#[derive(Serialize)]
474struct Context7Manifest {
475    #[serde(rename = "$schema")]
476    schema: &'static str,
477    #[serde(rename = "projectTitle")]
478    project_title: String,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    description: Option<String>,
481    folders: Vec<String>,
482}
483
484/// Truncate / drop a description string to fit Context7's 10-200 char
485/// contract. Under 10 characters gets dropped (Context7 rejects short
486/// descriptions); over 200 gets truncated to 199 + `…`.
487fn clamp_description(raw: &str) -> Option<String> {
488    let trimmed = raw.trim();
489    let char_count = trimmed.chars().count();
490    if char_count < 10 {
491        return None;
492    }
493    if char_count <= 200 {
494        return Some(trimmed.to_owned());
495    }
496    let mut out = String::with_capacity(200);
497    for ch in trimmed.chars().take(199) {
498        out.push(ch);
499    }
500    out.push('…');
501    Some(out)
502}
503
504/// Render a [DeepWiki](https://deepwiki.com) manifest (`.devin/wiki.json`)
505/// for a workspace.
506///
507/// Emits `repo_notes` (a single workspace-overview note built from the
508/// first crate root doc) and `pages[]` (one page per crate, plus a
509/// workspace root page whose children point at each crate). Modules
510/// are intentionally left out of the page list at v0.1: DeepWiki's
511/// default page cap is 30, and generating a page per module tends to
512/// exceed that on multi-crate workspaces without adding much wiki
513/// signal. A `--deepwiki-expand` option that walks modules is left as
514/// a follow-up phase.
515///
516/// The output is hard-capped at 30 pages so callers that pass
517/// unusually large workspaces still produce a spec-valid manifest;
518/// dropped crates are silently truncated rather than errored on
519/// (DeepWiki auto-crawl still covers them via GitHub).
520pub fn render_deepwiki_manifest(workspace: &IndexedWorkspace) -> String {
521    const DEEPWIKI_PAGE_CAP: usize = 30;
522
523    let workspace_name = workspace_title(workspace);
524    let workspace_summary_line = workspace_summary(workspace);
525
526    let mut pages = Vec::new();
527    pages.push(DeepWikiPage {
528        title: workspace_name.clone(),
529        purpose: match workspace_summary_line {
530            Some(s) => format!("Workspace overview: {s}"),
531            None => "Workspace overview.".to_owned(),
532        },
533        parent: None,
534    });
535
536    for krate in &workspace.crates {
537        let crate_summary = krate
538            .root_module_doc
539            .as_deref()
540            .and_then(first_line)
541            .unwrap_or("(no crate documentation)");
542        pages.push(DeepWikiPage {
543            title: format!("crate:{}", krate.name),
544            purpose: format!("{}: {crate_summary}", krate.name),
545            parent: Some(workspace_name.clone()),
546        });
547    }
548
549    pages.truncate(DEEPWIKI_PAGE_CAP);
550
551    let repo_notes = vec![DeepWikiNote {
552        content: match workspace_summary_line {
553            Some(s) => format!("Workspace `{workspace_name}` — {s}"),
554            None => format!("Workspace `{workspace_name}`."),
555        },
556        author: None,
557    }];
558
559    let manifest = DeepWikiManifest { repo_notes, pages };
560    let json = serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| String::from("{}"));
561    format!("{json}\n")
562}
563
564#[derive(Serialize)]
565struct DeepWikiManifest {
566    repo_notes: Vec<DeepWikiNote>,
567    pages: Vec<DeepWikiPage>,
568}
569
570#[derive(Serialize)]
571struct DeepWikiNote {
572    content: String,
573    #[serde(skip_serializing_if = "Option::is_none")]
574    author: Option<String>,
575}
576
577#[derive(Serialize)]
578struct DeepWikiPage {
579    title: String,
580    purpose: String,
581    #[serde(skip_serializing_if = "Option::is_none")]
582    parent: Option<String>,
583}
584
585/// Render the deterministic public-API surface JSON for a single crate.
586///
587/// The document intentionally excludes non-public items, unnameable
588/// items (impls, imports, associated items), and any information that
589/// changes across rustdoc runs without corresponding source changes
590/// (spans, IDs, etc.). Callers use this as the check target for
591/// `--check --strict`: if two runs produce different JSON, something in
592/// the crate's public surface actually moved.
593///
594/// Items are sorted lexicographically by path so diffs read cleanly.
595pub fn render_api_json(krate: &IndexedCrate) -> Result<String> {
596    let mut items = Vec::new();
597    let index = &krate.crate_data.index;
598
599    if let Some(root_item) = index.get(&krate.crate_data.root)
600        && let ItemEnum::Module(root_module) = &root_item.inner
601    {
602        let crate_prefix = crate_slug(&krate.name);
603        walk_api_items(index, root_module, crate_prefix, &mut items);
604    }
605
606    items.sort_by(|a, b| a.path.cmp(&b.path));
607
608    let surface = ApiSurface {
609        krate: krate.name.clone(),
610        version: krate.version.clone(),
611        items,
612    };
613    let json = serde_json::to_string_pretty(&surface)?;
614    Ok(format!("{json}\n"))
615}
616
617#[derive(Serialize)]
618struct ApiSurface {
619    #[serde(rename = "crate")]
620    krate: String,
621    version: String,
622    items: Vec<ApiItem>,
623}
624
625#[derive(Serialize)]
626struct ApiItem {
627    path: String,
628    kind: &'static str,
629    #[serde(skip_serializing_if = "Option::is_none")]
630    docs: Option<String>,
631}
632
633fn walk_api_items(
634    index: &std::collections::HashMap<rustdoc_types::Id, Item>,
635    module: &Module,
636    prefix: String,
637    out: &mut Vec<ApiItem>,
638) {
639    for child_id in &module.items {
640        let Some(child) = index.get(child_id) else {
641            continue;
642        };
643        if !matches!(child.visibility, Visibility::Public) {
644            continue;
645        }
646        let Some(name) = child.name.as_deref() else {
647            continue;
648        };
649        let path = format!("{prefix}::{name}");
650
651        if let Some(kind) = api_kind(&child.inner) {
652            out.push(ApiItem {
653                path: path.clone(),
654                kind,
655                docs: child.docs.clone(),
656            });
657        }
658
659        if let ItemEnum::Module(child_module) = &child.inner {
660            walk_api_items(index, child_module, path, out);
661        }
662    }
663}
664
665fn api_kind(inner: &ItemEnum) -> Option<&'static str> {
666    match inner {
667        ItemEnum::Module(_) => Some("module"),
668        ItemEnum::Function(_) => Some("function"),
669        ItemEnum::Struct(_) => Some("struct"),
670        ItemEnum::Enum(_) => Some("enum"),
671        ItemEnum::Union(_) => Some("union"),
672        ItemEnum::Trait(_) => Some("trait"),
673        ItemEnum::TypeAlias(_) => Some("type_alias"),
674        ItemEnum::Constant { .. } => Some("constant"),
675        ItemEnum::Static(_) => Some("static"),
676        ItemEnum::Macro(_) => Some("macro"),
677        ItemEnum::ProcMacro(_) => Some("proc_macro"),
678        _ => None,
679    }
680}
681
682// -------- helpers --------
683
684fn workspace_title(workspace: &IndexedWorkspace) -> String {
685    workspace
686        .root
687        .file_name()
688        .and_then(|s| s.to_str())
689        .map(str::to_owned)
690        .unwrap_or_else(|| "workspace".to_owned())
691}
692
693fn workspace_summary(workspace: &IndexedWorkspace) -> Option<&str> {
694    let raw = workspace
695        .crates
696        .iter()
697        .find_map(|c| c.root_module_doc.as_deref())?;
698    first_line(raw)
699}
700
701fn crate_slug(name: &str) -> String {
702    name.replace('-', "_")
703}
704
705/// Compute the on-disk slug for a module path.
706///
707/// Reserves the bare literal `"index"` so a top-level module named
708/// `index` (e.g. `aidoc_core::index`) does not collide with the
709/// crate-root `<crate_slug>/index.md` document. Colliding names are
710/// prefixed with an underscore (`index` → `_index`).
711fn module_slug(path: &str) -> String {
712    let slug = path.replace("::", "__");
713    if slug == "index" {
714        "_index".to_owned()
715    } else {
716        slug
717    }
718}
719
720fn first_line(doc: &str) -> Option<&str> {
721    doc.lines().map(str::trim).find(|line| !line.is_empty())
722}
723
724/// A public module surfaced under a crate root.
725#[derive(Debug, Clone)]
726pub struct PublicModule<'a> {
727    /// Dotted module path (`config`, `net::tcp`).
728    pub path: String,
729    /// The module's own doc comment, if any.
730    pub docs: Option<&'a str>,
731    /// The rustdoc id of the module item, used to look up its children.
732    id: rustdoc_types::Id,
733}
734
735fn public_modules(krate: &IndexedCrate) -> Vec<PublicModule<'_>> {
736    let mut out = Vec::new();
737    let index = &krate.crate_data.index;
738
739    let Some(root_item) = index.get(&krate.crate_data.root) else {
740        return out;
741    };
742    let ItemEnum::Module(root_module) = &root_item.inner else {
743        return out;
744    };
745
746    walk_module(index, root_module, String::new(), &mut out);
747    out
748}
749
750fn walk_module<'a>(
751    index: &'a std::collections::HashMap<rustdoc_types::Id, Item>,
752    module: &'a Module,
753    prefix: String,
754    out: &mut Vec<PublicModule<'a>>,
755) {
756    for child_id in &module.items {
757        let Some(child) = index.get(child_id) else {
758            continue;
759        };
760        if !matches!(child.visibility, Visibility::Public) {
761            continue;
762        }
763        let ItemEnum::Module(child_module) = &child.inner else {
764            continue;
765        };
766        let Some(name) = child.name.as_deref() else {
767            continue;
768        };
769
770        let path = if prefix.is_empty() {
771            name.to_owned()
772        } else {
773            format!("{prefix}::{name}")
774        };
775
776        out.push(PublicModule {
777            path: path.clone(),
778            docs: child.docs.as_deref(),
779            id: *child_id,
780        });
781
782        walk_module(index, child_module, path, out);
783    }
784}
785
786/// Classification of a public item for grouping under module docs.
787#[derive(Debug, Clone, Copy, PartialEq, Eq)]
788enum ItemKind {
789    Function,
790    Type,
791    Trait,
792    Constant,
793    Macro,
794}
795
796struct RenderedItem<'a> {
797    name: &'a str,
798    summary: &'a str,
799    kind: ItemKind,
800}
801
802fn classify(item: &Item) -> Option<ItemKind> {
803    match &item.inner {
804        ItemEnum::Function(_) => Some(ItemKind::Function),
805        ItemEnum::Struct(_) | ItemEnum::Enum(_) | ItemEnum::Union(_) | ItemEnum::TypeAlias(_) => {
806            Some(ItemKind::Type)
807        }
808        ItemEnum::Trait(_) => Some(ItemKind::Trait),
809        ItemEnum::Constant { .. } | ItemEnum::Static(_) => Some(ItemKind::Constant),
810        ItemEnum::Macro(_) | ItemEnum::ProcMacro(_) => Some(ItemKind::Macro),
811        _ => None,
812    }
813}
814
815fn module_items<'a>(krate: &'a IndexedCrate, module: &PublicModule<'_>) -> Vec<RenderedItem<'a>> {
816    let mut out = Vec::new();
817    let index = &krate.crate_data.index;
818
819    let Some(item) = index.get(&module.id) else {
820        return out;
821    };
822    let ItemEnum::Module(module_data) = &item.inner else {
823        return out;
824    };
825
826    for child_id in &module_data.items {
827        let Some(child) = index.get(child_id) else {
828            continue;
829        };
830        if !matches!(child.visibility, Visibility::Public) {
831            continue;
832        }
833        let Some(kind) = classify(child) else {
834            continue;
835        };
836        let Some(name) = child.name.as_deref() else {
837            continue;
838        };
839        let summary = child
840            .docs
841            .as_deref()
842            .and_then(first_line)
843            .unwrap_or("(no documentation)");
844        out.push(RenderedItem {
845            name,
846            summary,
847            kind,
848        });
849    }
850
851    // Deterministic within-kind order: alphabetic by name.
852    out.sort_by(|a, b| a.name.cmp(b.name));
853    out
854}
855
856fn render_item_group(out: &mut String, heading: &str, kind: ItemKind, items: &[RenderedItem<'_>]) {
857    let group: Vec<&RenderedItem<'_>> = items.iter().filter(|i| i.kind == kind).collect();
858    if group.is_empty() {
859        return;
860    }
861    writeln!(out, "## {heading}").unwrap();
862    out.push('\n');
863    for item in group {
864        writeln!(out, "- `{}` — {}", item.name, item.summary).unwrap();
865    }
866    out.push('\n');
867}
868
869#[cfg(test)]
870mod tests {
871    use super::*;
872
873    /// A top-level module named `index` must not resolve to a slug
874    /// that collides with the crate-root `<crate>/index.md` document.
875    /// The `aidoc-core` crate itself contains an `index` module, so
876    /// this regression is dogfood-visible.
877    #[test]
878    fn module_slug_reserves_index_for_crate_root() {
879        assert_eq!(module_slug("index"), "_index");
880        // Nested `sub::index` is not at slug level `index`, so it
881        // stays as-is (no collision with the crate root).
882        assert_eq!(module_slug("sub::index"), "sub__index");
883        // Normal module names round-trip unchanged.
884        assert_eq!(module_slug("config"), "config");
885    }
886}