1use 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#[derive(Debug, Clone)]
34pub struct Artifact {
35 pub location: ArtifactLocation,
37 pub path: String,
40 pub body: String,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum ArtifactLocation {
53 #[default]
56 OutDir,
57 WorkspaceRoot,
60}
61
62impl Artifact {
63 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 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
84pub 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
129pub 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 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 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
285pub 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
349pub 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
396pub 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
430pub 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
452pub 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
484fn 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
504pub 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
585pub 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
682fn 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
705fn 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#[derive(Debug, Clone)]
726pub struct PublicModule<'a> {
727 pub path: String,
729 pub docs: Option<&'a str>,
731 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#[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 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 #[test]
878 fn module_slug_reserves_index_for_crate_root() {
879 assert_eq!(module_slug("index"), "_index");
880 assert_eq!(module_slug("sub::index"), "sub__index");
883 assert_eq!(module_slug("config"), "config");
885 }
886}