1use 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#[derive(Debug, Clone)]
33pub struct Artifact {
34 pub location: ArtifactLocation,
36 pub path: String,
39 pub body: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum ArtifactLocation {
52 #[default]
55 OutDir,
56 WorkspaceRoot,
59}
60
61impl Artifact {
62 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 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
83pub 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
128pub 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
192pub 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
239pub 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
273pub 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
295pub 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
327fn 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
347pub 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
428pub 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
525fn 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#[derive(Debug, Clone)]
558pub struct PublicModule<'a> {
559 pub path: String,
561 pub docs: Option<&'a str>,
563 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#[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 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}