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::index::{IndexedCrate, IndexedWorkspace};
25
26/// One generated artifact ready to be written to disk.
27///
28/// The `path` is relative to the base directory chosen by
29/// [`location`](Self::location). The `body` is the exact bytes to
30/// write; the renderer already appended a trailing newline where the
31/// artifact family expects one.
32#[derive(Debug, Clone)]
33pub struct Artifact {
34    /// Which base directory `path` is relative to.
35    pub location: ArtifactLocation,
36    /// Path relative to the artifact's base directory (uses forward
37    /// slashes).
38    pub path: String,
39    /// Full file contents.
40    pub body: String,
41}
42
43/// Which base directory an [`Artifact`]'s path is relative to.
44///
45/// Core `Preset::Publish` output goes into [`OutDir`](Self::OutDir).
46/// Platform overlays that need to place a manifest at the repository
47/// root (e.g. `context7.json`, `.devin/wiki.json`) use
48/// [`WorkspaceRoot`](Self::WorkspaceRoot) so callers know not to
49/// collapse them under `out_dir`.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ArtifactLocation {
52    /// Path is relative to `Config::out_dir`
53    /// (default `<workspace>/docs/aidoc/`).
54    #[default]
55    OutDir,
56    /// Path is relative to the workspace root (the parent of the
57    /// top-level `Cargo.toml`).
58    WorkspaceRoot,
59}
60
61impl Artifact {
62    /// Convenience constructor for artifacts that land under
63    /// `Config::out_dir`.
64    pub fn in_out_dir(path: impl Into<String>, body: impl Into<String>) -> Self {
65        Self {
66            location: ArtifactLocation::OutDir,
67            path: path.into(),
68            body: body.into(),
69        }
70    }
71
72    /// Convenience constructor for artifacts that land at the
73    /// workspace root (typically platform manifests).
74    pub fn in_workspace_root(path: impl Into<String>, body: impl Into<String>) -> Self {
75        Self {
76            location: ArtifactLocation::WorkspaceRoot,
77            path: path.into(),
78            body: body.into(),
79        }
80    }
81}
82
83/// Render every artifact in the Preset::Publish set for a workspace.
84///
85/// The returned artifacts appear in a stable order: `llms.txt` first,
86/// then `<crate>/index.md` and `<crate>/<module>.md` for each crate in
87/// discovery order, then `api/<crate>.json` for each crate, and finally
88/// `llms-full.txt`. Callers that want to write only a subset can filter
89/// by `path`.
90pub fn render_all(workspace: &IndexedWorkspace, title: Option<&str>) -> Result<Vec<Artifact>> {
91    let mut artifacts = Vec::new();
92
93    artifacts.push(Artifact::in_out_dir(
94        "llms.txt",
95        render_llms_txt(workspace, title),
96    ));
97
98    for krate in &workspace.crates {
99        let slug = crate_slug(&krate.name);
100        artifacts.push(Artifact::in_out_dir(
101            format!("{slug}/index.md"),
102            render_crate_index(krate),
103        ));
104
105        for module in public_modules(krate) {
106            artifacts.push(Artifact::in_out_dir(
107                format!("{slug}/{}.md", module_slug(&module.path)),
108                render_module(krate, &module),
109            ));
110        }
111    }
112
113    for krate in &workspace.crates {
114        artifacts.push(Artifact::in_out_dir(
115            format!("api/{}.json", crate_slug(&krate.name)),
116            render_api_json(krate)?,
117        ));
118    }
119
120    artifacts.push(Artifact::in_out_dir(
121        "llms-full.txt",
122        render_llms_full(&artifacts),
123    ));
124
125    Ok(artifacts)
126}
127
128/// Render the top-level `llms.txt` index for a workspace.
129///
130/// The output follows the [llmstxt.org](https://llmstxt.org) structure:
131/// an H1 title, an optional blockquote summary, then one H2 section per
132/// crate whose bullet list points at the per-crate / per-module markdown
133/// documents that the narrative renderer emits.
134///
135/// The `title` argument overrides the default heading (which is the
136/// basename of the workspace root). Pass `None` to accept the default.
137pub fn render_llms_txt(workspace: &IndexedWorkspace, title: Option<&str>) -> String {
138    let mut out = String::new();
139
140    let heading = title
141        .map(str::to_owned)
142        .unwrap_or_else(|| workspace_title(workspace));
143    writeln!(&mut out, "# {heading}").unwrap();
144    out.push('\n');
145
146    if let Some(summary) = workspace_summary(workspace) {
147        writeln!(&mut out, "> {summary}").unwrap();
148        out.push('\n');
149    }
150
151    for krate in &workspace.crates {
152        writeln!(&mut out, "## {} {}", krate.name, krate.version).unwrap();
153        out.push('\n');
154
155        let crate_slug = crate_slug(&krate.name);
156        let overview_summary = krate
157            .root_module_doc
158            .as_deref()
159            .and_then(first_line)
160            .unwrap_or("(no crate-root documentation)");
161        writeln!(
162            &mut out,
163            "- [{name} overview]({slug}/index.md): {summary}",
164            name = krate.name,
165            slug = crate_slug,
166            summary = overview_summary,
167        )
168        .unwrap();
169
170        for module in public_modules(krate) {
171            let module_summary = module
172                .docs
173                .and_then(first_line)
174                .unwrap_or("(no module-level documentation)");
175            writeln!(
176                &mut out,
177                "- [{name}::{path}]({slug}/{module_slug}.md): {summary}",
178                name = krate.name,
179                path = module.path,
180                slug = crate_slug,
181                module_slug = module_slug(&module.path),
182                summary = module_summary,
183            )
184            .unwrap();
185        }
186        out.push('\n');
187    }
188
189    out
190}
191
192/// Render the per-crate `index.md` narrative document.
193///
194/// The document contains the full crate-root doc comment followed by a
195/// module list. Missing docs show a short placeholder so downstream
196/// readers can tell "no docs" from "docs deliberately empty".
197pub fn render_crate_index(krate: &IndexedCrate) -> String {
198    let mut out = String::new();
199    writeln!(&mut out, "# {} {}", krate.name, krate.version).unwrap();
200    out.push('\n');
201
202    match krate.root_module_doc.as_deref() {
203        Some(doc) => {
204            out.push_str(doc);
205            if !doc.ends_with('\n') {
206                out.push('\n');
207            }
208            out.push('\n');
209        }
210        None => {
211            out.push_str("_This crate has no crate-root documentation._\n\n");
212        }
213    }
214
215    let modules = public_modules(krate);
216    if !modules.is_empty() {
217        writeln!(&mut out, "## Modules").unwrap();
218        out.push('\n');
219        for module in &modules {
220            let module_summary = module
221                .docs
222                .and_then(first_line)
223                .unwrap_or("(no module-level documentation)");
224            writeln!(
225                &mut out,
226                "- [`{path}`]({slug}.md): {summary}",
227                path = module.path,
228                slug = module_slug(&module.path),
229                summary = module_summary,
230            )
231            .unwrap();
232        }
233        out.push('\n');
234    }
235
236    out
237}
238
239/// Render the per-module narrative document.
240///
241/// Each module document contains the module-level doc comment followed by
242/// a public-item reference organised by kind (functions, types, traits,
243/// constants, macros). Item summaries are the first non-empty line of the
244/// corresponding doc comment; items with no docs show a placeholder.
245pub fn render_module(krate: &IndexedCrate, module: &PublicModule<'_>) -> String {
246    let mut out = String::new();
247    writeln!(&mut out, "# {}::{}", krate.name, module.path).unwrap();
248    out.push('\n');
249
250    match module.docs {
251        Some(doc) => {
252            out.push_str(doc);
253            if !doc.ends_with('\n') {
254                out.push('\n');
255            }
256            out.push('\n');
257        }
258        None => {
259            out.push_str("_This module has no module-level documentation._\n\n");
260        }
261    }
262
263    let items = module_items(krate, module);
264    render_item_group(&mut out, "Functions", ItemKind::Function, &items);
265    render_item_group(&mut out, "Types", ItemKind::Type, &items);
266    render_item_group(&mut out, "Traits", ItemKind::Trait, &items);
267    render_item_group(&mut out, "Constants", ItemKind::Constant, &items);
268    render_item_group(&mut out, "Macros", ItemKind::Macro, &items);
269
270    out
271}
272
273/// Render `llms-full.txt`: every markdown artifact concatenated, with a
274/// short header before each chunk so the reader can identify boundaries.
275///
276/// This intentionally skips non-markdown artifacts (`llms.txt` itself and
277/// any future JSON payloads) — those already have their own well-known
278/// paths, and duplicating them here would only bloat the file.
279pub fn render_llms_full(artifacts: &[Artifact]) -> String {
280    let mut out = String::new();
281    for artifact in artifacts {
282        if !artifact.path.ends_with(".md") {
283            continue;
284        }
285        writeln!(&mut out, "<!-- {} -->", artifact.path).unwrap();
286        out.push_str(&artifact.body);
287        if !artifact.body.ends_with('\n') {
288            out.push('\n');
289        }
290        out.push('\n');
291    }
292    out
293}
294
295/// Render a [Context7](https://context7.com) manifest (`context7.json`)
296/// for a workspace.
297///
298/// Emits the minimum useful field set: `$schema`, `projectTitle`,
299/// `description` (first non-empty line of the first crate's root doc,
300/// truncated to Context7's 10-200 character range), and `folders`
301/// (pointing at cargo-aidoc's default output tree). Callers place the
302/// returned body at the workspace root; the [`crate::platform`]
303/// dispatcher wires this up when [`crate::Platform::Context7`] is
304/// selected.
305pub fn render_context7_manifest(workspace: &IndexedWorkspace) -> String {
306    let manifest = Context7Manifest {
307        schema: "https://context7.com/schema/context7.json",
308        project_title: workspace_title(workspace),
309        description: workspace_summary(workspace).and_then(clamp_description),
310        folders: vec!["docs/aidoc".to_owned()],
311    };
312    let json = serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| String::from("{}"));
313    format!("{json}\n")
314}
315
316#[derive(Serialize)]
317struct Context7Manifest {
318    #[serde(rename = "$schema")]
319    schema: &'static str,
320    #[serde(rename = "projectTitle")]
321    project_title: String,
322    #[serde(skip_serializing_if = "Option::is_none")]
323    description: Option<String>,
324    folders: Vec<String>,
325}
326
327/// Truncate / drop a description string to fit Context7's 10-200 char
328/// contract. Under 10 characters gets dropped (Context7 rejects short
329/// descriptions); over 200 gets truncated to 199 + `…`.
330fn clamp_description(raw: &str) -> Option<String> {
331    let trimmed = raw.trim();
332    let char_count = trimmed.chars().count();
333    if char_count < 10 {
334        return None;
335    }
336    if char_count <= 200 {
337        return Some(trimmed.to_owned());
338    }
339    let mut out = String::with_capacity(200);
340    for ch in trimmed.chars().take(199) {
341        out.push(ch);
342    }
343    out.push('…');
344    Some(out)
345}
346
347/// Render a [DeepWiki](https://deepwiki.com) manifest (`.devin/wiki.json`)
348/// for a workspace.
349///
350/// Emits `repo_notes` (a single workspace-overview note built from the
351/// first crate root doc) and `pages[]` (one page per crate, plus a
352/// workspace root page whose children point at each crate). Modules
353/// are intentionally left out of the page list at v0.1: DeepWiki's
354/// default page cap is 30, and generating a page per module tends to
355/// exceed that on multi-crate workspaces without adding much wiki
356/// signal. A `--deepwiki-expand` option that walks modules is left as
357/// a follow-up phase.
358///
359/// The output is hard-capped at 30 pages so callers that pass
360/// unusually large workspaces still produce a spec-valid manifest;
361/// dropped crates are silently truncated rather than errored on
362/// (DeepWiki auto-crawl still covers them via GitHub).
363pub fn render_deepwiki_manifest(workspace: &IndexedWorkspace) -> String {
364    const DEEPWIKI_PAGE_CAP: usize = 30;
365
366    let workspace_name = workspace_title(workspace);
367    let workspace_summary_line = workspace_summary(workspace);
368
369    let mut pages = Vec::new();
370    pages.push(DeepWikiPage {
371        title: workspace_name.clone(),
372        purpose: match workspace_summary_line {
373            Some(s) => format!("Workspace overview: {s}"),
374            None => "Workspace overview.".to_owned(),
375        },
376        parent: None,
377    });
378
379    for krate in &workspace.crates {
380        let crate_summary = krate
381            .root_module_doc
382            .as_deref()
383            .and_then(first_line)
384            .unwrap_or("(no crate documentation)");
385        pages.push(DeepWikiPage {
386            title: format!("crate:{}", krate.name),
387            purpose: format!("{}: {crate_summary}", krate.name),
388            parent: Some(workspace_name.clone()),
389        });
390    }
391
392    pages.truncate(DEEPWIKI_PAGE_CAP);
393
394    let repo_notes = vec![DeepWikiNote {
395        content: match workspace_summary_line {
396            Some(s) => format!("Workspace `{workspace_name}` — {s}"),
397            None => format!("Workspace `{workspace_name}`."),
398        },
399        author: None,
400    }];
401
402    let manifest = DeepWikiManifest { repo_notes, pages };
403    let json = serde_json::to_string_pretty(&manifest).unwrap_or_else(|_| String::from("{}"));
404    format!("{json}\n")
405}
406
407#[derive(Serialize)]
408struct DeepWikiManifest {
409    repo_notes: Vec<DeepWikiNote>,
410    pages: Vec<DeepWikiPage>,
411}
412
413#[derive(Serialize)]
414struct DeepWikiNote {
415    content: String,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    author: Option<String>,
418}
419
420#[derive(Serialize)]
421struct DeepWikiPage {
422    title: String,
423    purpose: String,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    parent: Option<String>,
426}
427
428/// Render the deterministic public-API surface JSON for a single crate.
429///
430/// The document intentionally excludes non-public items, unnameable
431/// items (impls, imports, associated items), and any information that
432/// changes across rustdoc runs without corresponding source changes
433/// (spans, IDs, etc.). Callers use this as the check target for
434/// `--check --strict`: if two runs produce different JSON, something in
435/// the crate's public surface actually moved.
436///
437/// Items are sorted lexicographically by path so diffs read cleanly.
438pub fn render_api_json(krate: &IndexedCrate) -> Result<String> {
439    let mut items = Vec::new();
440    let index = &krate.crate_data.index;
441
442    if let Some(root_item) = index.get(&krate.crate_data.root)
443        && let ItemEnum::Module(root_module) = &root_item.inner
444    {
445        let crate_prefix = crate_slug(&krate.name);
446        walk_api_items(index, root_module, crate_prefix, &mut items);
447    }
448
449    items.sort_by(|a, b| a.path.cmp(&b.path));
450
451    let surface = ApiSurface {
452        krate: krate.name.clone(),
453        version: krate.version.clone(),
454        items,
455    };
456    let json = serde_json::to_string_pretty(&surface)?;
457    Ok(format!("{json}\n"))
458}
459
460#[derive(Serialize)]
461struct ApiSurface {
462    #[serde(rename = "crate")]
463    krate: String,
464    version: String,
465    items: Vec<ApiItem>,
466}
467
468#[derive(Serialize)]
469struct ApiItem {
470    path: String,
471    kind: &'static str,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    docs: Option<String>,
474}
475
476fn walk_api_items(
477    index: &std::collections::HashMap<rustdoc_types::Id, Item>,
478    module: &Module,
479    prefix: String,
480    out: &mut Vec<ApiItem>,
481) {
482    for child_id in &module.items {
483        let Some(child) = index.get(child_id) else {
484            continue;
485        };
486        if !matches!(child.visibility, Visibility::Public) {
487            continue;
488        }
489        let Some(name) = child.name.as_deref() else {
490            continue;
491        };
492        let path = format!("{prefix}::{name}");
493
494        if let Some(kind) = api_kind(&child.inner) {
495            out.push(ApiItem {
496                path: path.clone(),
497                kind,
498                docs: child.docs.clone(),
499            });
500        }
501
502        if let ItemEnum::Module(child_module) = &child.inner {
503            walk_api_items(index, child_module, path, out);
504        }
505    }
506}
507
508fn api_kind(inner: &ItemEnum) -> Option<&'static str> {
509    match inner {
510        ItemEnum::Module(_) => Some("module"),
511        ItemEnum::Function(_) => Some("function"),
512        ItemEnum::Struct(_) => Some("struct"),
513        ItemEnum::Enum(_) => Some("enum"),
514        ItemEnum::Union(_) => Some("union"),
515        ItemEnum::Trait(_) => Some("trait"),
516        ItemEnum::TypeAlias(_) => Some("type_alias"),
517        ItemEnum::Constant { .. } => Some("constant"),
518        ItemEnum::Static(_) => Some("static"),
519        ItemEnum::Macro(_) => Some("macro"),
520        ItemEnum::ProcMacro(_) => Some("proc_macro"),
521        _ => None,
522    }
523}
524
525// -------- helpers --------
526
527fn workspace_title(workspace: &IndexedWorkspace) -> String {
528    workspace
529        .root
530        .file_name()
531        .and_then(|s| s.to_str())
532        .map(str::to_owned)
533        .unwrap_or_else(|| "workspace".to_owned())
534}
535
536fn workspace_summary(workspace: &IndexedWorkspace) -> Option<&str> {
537    let raw = workspace
538        .crates
539        .iter()
540        .find_map(|c| c.root_module_doc.as_deref())?;
541    first_line(raw)
542}
543
544fn crate_slug(name: &str) -> String {
545    name.replace('-', "_")
546}
547
548fn module_slug(path: &str) -> String {
549    path.replace("::", "__")
550}
551
552fn first_line(doc: &str) -> Option<&str> {
553    doc.lines().map(str::trim).find(|line| !line.is_empty())
554}
555
556/// A public module surfaced under a crate root.
557#[derive(Debug, Clone)]
558pub struct PublicModule<'a> {
559    /// Dotted module path (`config`, `net::tcp`).
560    pub path: String,
561    /// The module's own doc comment, if any.
562    pub docs: Option<&'a str>,
563    /// The rustdoc id of the module item, used to look up its children.
564    id: rustdoc_types::Id,
565}
566
567fn public_modules(krate: &IndexedCrate) -> Vec<PublicModule<'_>> {
568    let mut out = Vec::new();
569    let index = &krate.crate_data.index;
570
571    let Some(root_item) = index.get(&krate.crate_data.root) else {
572        return out;
573    };
574    let ItemEnum::Module(root_module) = &root_item.inner else {
575        return out;
576    };
577
578    walk_module(index, root_module, String::new(), &mut out);
579    out
580}
581
582fn walk_module<'a>(
583    index: &'a std::collections::HashMap<rustdoc_types::Id, Item>,
584    module: &'a Module,
585    prefix: String,
586    out: &mut Vec<PublicModule<'a>>,
587) {
588    for child_id in &module.items {
589        let Some(child) = index.get(child_id) else {
590            continue;
591        };
592        if !matches!(child.visibility, Visibility::Public) {
593            continue;
594        }
595        let ItemEnum::Module(child_module) = &child.inner else {
596            continue;
597        };
598        let Some(name) = child.name.as_deref() else {
599            continue;
600        };
601
602        let path = if prefix.is_empty() {
603            name.to_owned()
604        } else {
605            format!("{prefix}::{name}")
606        };
607
608        out.push(PublicModule {
609            path: path.clone(),
610            docs: child.docs.as_deref(),
611            id: *child_id,
612        });
613
614        walk_module(index, child_module, path, out);
615    }
616}
617
618/// Classification of a public item for grouping under module docs.
619#[derive(Debug, Clone, Copy, PartialEq, Eq)]
620enum ItemKind {
621    Function,
622    Type,
623    Trait,
624    Constant,
625    Macro,
626}
627
628struct RenderedItem<'a> {
629    name: &'a str,
630    summary: &'a str,
631    kind: ItemKind,
632}
633
634fn classify(item: &Item) -> Option<ItemKind> {
635    match &item.inner {
636        ItemEnum::Function(_) => Some(ItemKind::Function),
637        ItemEnum::Struct(_) | ItemEnum::Enum(_) | ItemEnum::Union(_) | ItemEnum::TypeAlias(_) => {
638            Some(ItemKind::Type)
639        }
640        ItemEnum::Trait(_) => Some(ItemKind::Trait),
641        ItemEnum::Constant { .. } | ItemEnum::Static(_) => Some(ItemKind::Constant),
642        ItemEnum::Macro(_) | ItemEnum::ProcMacro(_) => Some(ItemKind::Macro),
643        _ => None,
644    }
645}
646
647fn module_items<'a>(krate: &'a IndexedCrate, module: &PublicModule<'_>) -> Vec<RenderedItem<'a>> {
648    let mut out = Vec::new();
649    let index = &krate.crate_data.index;
650
651    let Some(item) = index.get(&module.id) else {
652        return out;
653    };
654    let ItemEnum::Module(module_data) = &item.inner else {
655        return out;
656    };
657
658    for child_id in &module_data.items {
659        let Some(child) = index.get(child_id) else {
660            continue;
661        };
662        if !matches!(child.visibility, Visibility::Public) {
663            continue;
664        }
665        let Some(kind) = classify(child) else {
666            continue;
667        };
668        let Some(name) = child.name.as_deref() else {
669            continue;
670        };
671        let summary = child
672            .docs
673            .as_deref()
674            .and_then(first_line)
675            .unwrap_or("(no documentation)");
676        out.push(RenderedItem {
677            name,
678            summary,
679            kind,
680        });
681    }
682
683    // Deterministic within-kind order: alphabetic by name.
684    out.sort_by(|a, b| a.name.cmp(b.name));
685    out
686}
687
688fn render_item_group(out: &mut String, heading: &str, kind: ItemKind, items: &[RenderedItem<'_>]) {
689    let group: Vec<&RenderedItem<'_>> = items.iter().filter(|i| i.kind == kind).collect();
690    if group.is_empty() {
691        return;
692    }
693    writeln!(out, "## {heading}").unwrap();
694    out.push('\n');
695    for item in group {
696        writeln!(out, "- `{}` — {}", item.name, item.summary).unwrap();
697    }
698    out.push('\n');
699}