1use crate::core::backend::GeneratedFile;
7use crate::core::config::{Language, ResolvedCrateConfig};
8use crate::core::ir::ApiSurface;
9use heck::ToPascalCase;
10use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13mod context;
14mod descriptions;
15pub mod doc_cleaning;
16mod examples;
17mod formatting;
18mod language_pages;
19mod naming;
20mod render;
21mod rust_static;
22mod shared_pages;
23mod signatures;
24mod sorting;
25mod template_env;
26#[cfg(test)]
27mod tests;
28mod type_mapping;
29mod version_labels;
30
31#[cfg(test)]
32pub(crate) mod test_helpers;
33
34pub use doc_cleaning::clean_doc;
35pub use type_mapping::doc_type;
36
37pub use context::{CliSurface, DocsRenderContext, McpSurface};
38
39pub fn generate_docs(
44 api: &ApiSurface,
45 config: &ResolvedCrateConfig,
46 languages: &[Language],
47 output_dir: &str,
48) -> anyhow::Result<Vec<GeneratedFile>> {
49 let mut files = Vec::new();
50 let ffi_prefix = &config.ffi_prefix().to_pascal_case();
51
52 for &lang in languages {
53 files.push(language_pages::generate_lang_doc(
54 api, config, lang, output_dir, ffi_prefix,
55 )?);
56 }
57
58 files.push(shared_pages::generate_configuration_doc(api, config, output_dir)?);
59 files.push(shared_pages::generate_types_doc(api, output_dir)?);
60 files.push(shared_pages::generate_errors_doc(api, output_dir)?);
61
62 for file in &mut files {
63 file.content = doc_cleaning::wrap_bare_urls(&file.content);
64 if !file.content.ends_with('\n') {
65 file.content.push('\n');
66 }
67 }
68
69 Ok(files)
70}
71
72pub fn generate_docs_stage(
75 api: &ApiSurface,
76 config: &ResolvedCrateConfig,
77 languages: &[Language],
78 output_override: Option<&str>,
79 workspace_root: &Path,
80) -> anyhow::Result<Vec<GeneratedFile>> {
81 let reference_output = output_override
82 .map(PathBuf::from)
83 .or_else(|| config.docs.as_ref().and_then(|docs| docs.reference_output.clone()))
84 .unwrap_or_else(|| PathBuf::from("docs/reference"));
85 let reference_output_str = reference_output.to_string_lossy().to_string();
86
87 let mut files = generate_docs(api, config, languages, &reference_output_str)?;
88 for file in &mut files {
89 file.content = with_markdown_alef_header(&file.content);
90 file.generated_header = true;
91 }
92
93 let mut context = build_base_context(api, config, languages, &files);
94 let snippet_dirs = build_snippet_context(config, workspace_root, &mut context)?;
95
96 if let Some(docs_cfg) = &config.docs {
97 if let Some(cli_cfg) = &docs_cfg.cli
98 && cli_cfg.is_enabled()
99 {
100 let explicit_sources = !cli_cfg.sources.is_empty();
101 let sources = docs_sources(config, &cli_cfg.sources, workspace_root);
102 warn_missing_explicit_sources("CLI", &cli_cfg.sources, workspace_root);
103 let surface = rust_static::extract_cli_surface(&sources)?;
104 if surface.commands.is_empty() {
105 if explicit_sources {
106 tracing::warn!("docs.cli was configured but no clap commands were discovered");
107 }
108 } else {
109 let path = cli_cfg
110 .output
111 .clone()
112 .unwrap_or_else(|| reference_output.join("cli.md"));
113 render::ensure_managed_or_adopted(workspace_root, &path, cli_cfg.adopt_existing)?;
114 files.push(render::generate_cli_doc(&surface, path.clone()));
115 context.references.push(context::ReferenceDoc {
116 kind: "cli".to_string(),
117 title: "CLI Reference".to_string(),
118 path: path.to_string_lossy().to_string(),
119 });
120 context.cli = surface;
121 }
122 }
123
124 if let Some(mcp_cfg) = &docs_cfg.mcp
125 && mcp_cfg.is_enabled()
126 {
127 let explicit_sources = !mcp_cfg.sources.is_empty();
128 let sources = docs_sources(config, &mcp_cfg.sources, workspace_root);
129 warn_missing_explicit_sources("MCP", &mcp_cfg.sources, workspace_root);
130 let surface = rust_static::extract_mcp_surface(&sources)?;
131 if surface.tools.is_empty() && surface.prompts.is_empty() && surface.resources.is_empty() {
132 if explicit_sources {
133 tracing::warn!("docs.mcp was configured but no rmcp tools, prompts, or resources were discovered");
134 }
135 } else {
136 let path = mcp_cfg
137 .output
138 .clone()
139 .unwrap_or_else(|| reference_output.join("mcp.md"));
140 render::ensure_managed_or_adopted(workspace_root, &path, mcp_cfg.adopt_existing)?;
141 files.push(render::generate_mcp_doc(&surface, path.clone()));
142 context.references.push(context::ReferenceDoc {
143 kind: "mcp".to_string(),
144 title: "MCP Reference".to_string(),
145 path: path.to_string_lossy().to_string(),
146 });
147 context.mcp = surface;
148 }
149 }
150
151 if let Some(llms_cfg) = &docs_cfg.llms {
152 files.push(render::render_llms(llms_cfg, &context, workspace_root, &snippet_dirs)?);
153 }
154
155 if let Some(skills_cfg) = &docs_cfg.skills {
156 files.extend(render::render_skills(
157 skills_cfg,
158 &context,
159 workspace_root,
160 &snippet_dirs,
161 )?);
162 }
163 }
164
165 for file in &mut files {
166 file.content = doc_cleaning::wrap_bare_urls(&file.content);
167 if !file.content.ends_with('\n') {
168 file.content.push('\n');
169 }
170 }
171
172 Ok(files)
173}
174
175fn build_base_context(
176 api: &ApiSurface,
177 config: &ResolvedCrateConfig,
178 languages: &[Language],
179 api_files: &[GeneratedFile],
180) -> DocsRenderContext {
181 let description = config
182 .scaffold
183 .as_ref()
184 .and_then(|scaffold| scaffold.description.clone())
185 .unwrap_or_else(|| format!("Bindings for {}", config.name));
186 let license = config
187 .scaffold
188 .as_ref()
189 .and_then(|scaffold| scaffold.license.clone())
190 .unwrap_or_else(|| "MIT".to_string());
191 let api_references = api_files
192 .iter()
193 .map(|file| {
194 let path = file.path.to_string_lossy().to_string();
195 context::ReferenceDoc {
196 kind: "api".to_string(),
197 title: path
198 .rsplit('/')
199 .next()
200 .unwrap_or(path.as_str())
201 .trim_end_matches(".md")
202 .replace('-', " "),
203 path,
204 }
205 })
206 .collect::<Vec<_>>();
207
208 DocsRenderContext {
209 krate: context::CrateDocsContext {
210 name: config.name.clone(),
211 version: api.version.clone(),
212 description,
213 repository: config.github_repo(),
214 license,
215 },
216 languages: languages.iter().map(ToString::to_string).collect(),
217 references: api_references.clone(),
218 api_references,
219 ..DocsRenderContext::default()
220 }
221}
222
223fn build_snippet_context(
224 config: &ResolvedCrateConfig,
225 workspace_root: &Path,
226 context: &mut DocsRenderContext,
227) -> anyhow::Result<Vec<PathBuf>> {
228 let Some(snippet_cfg) = config.docs.as_ref().and_then(|docs| docs.snippets.as_ref()) else {
229 return Ok(Vec::new());
230 };
231
232 for dir in &snippet_cfg.dirs {
233 if !workspace_root.join(dir).exists() {
234 tracing::warn!("docs.snippets.dirs entry does not exist, skipping: {}", dir.display());
235 }
236 }
237 let snippet_dirs = snippet_cfg
238 .dirs
239 .iter()
240 .filter(|dir| workspace_root.join(dir).exists())
241 .cloned()
242 .collect::<Vec<_>>();
243 if snippet_dirs.is_empty() {
244 if snippet_cfg.validation_level.is_some() || !snippet_cfg.required_languages.is_empty() {
245 tracing::warn!("docs.snippets is configured for validation but no snippet directories exist");
246 }
247 return Ok(Vec::new());
248 }
249
250 let absolute_snippet_dirs = snippet_dirs
251 .iter()
252 .map(|dir| workspace_root.join(dir))
253 .collect::<Vec<_>>();
254 let snippets = crate::snippets::discovery::discover_snippets(&absolute_snippet_dirs, None)?;
255 let mut counts_by_language = BTreeMap::new();
256 for snippet in &snippets {
257 *counts_by_language.entry(snippet.language.to_string()).or_insert(0) += 1;
258 }
259 context.snippets = context::SnippetIndexContext {
260 dirs: snippet_dirs
261 .iter()
262 .map(|dir| dir.to_string_lossy().to_string())
263 .collect(),
264 snippets: snippets
265 .iter()
266 .map(|snippet| context::SnippetContext {
267 id: snippet.id.clone(),
268 path: snippet.path.to_string_lossy().to_string(),
269 language: snippet.language.to_string(),
270 title: snippet.title.clone(),
271 tags: snippet.metadata.tags.clone(),
272 })
273 .collect(),
274 counts_by_language,
275 };
276
277 validate_snippets(config, workspace_root, snippet_cfg, &absolute_snippet_dirs, &snippets)?;
278 Ok(snippet_dirs)
279}
280
281fn validate_snippets(
282 config: &ResolvedCrateConfig,
283 workspace_root: &Path,
284 snippet_cfg: &crate::core::config::DocsSnippetsConfig,
285 absolute_snippet_dirs: &[PathBuf],
286 snippets: &[crate::snippets::types::Snippet],
287) -> anyhow::Result<()> {
288 let docs_dirs = if snippet_cfg.docs_dirs.is_empty() {
289 Vec::new()
290 } else {
291 snippet_cfg
292 .docs_dirs
293 .iter()
294 .map(|dir| workspace_root.join(dir))
295 .collect::<Vec<_>>()
296 };
297 let include_base_paths = snippet_cfg
298 .include_base_paths
299 .iter()
300 .map(|dir| workspace_root.join(dir))
301 .collect::<Vec<_>>();
302
303 if !docs_dirs.is_empty() {
304 let audit_report = crate::snippets::audit::audit(&crate::snippets::audit::AuditConfig {
305 docs_dirs: docs_dirs.clone(),
306 snippet_dirs: absolute_snippet_dirs.to_vec(),
307 require_frontmatter: snippet_cfg.require_frontmatter,
308 });
309 if audit_report.has_errors() {
310 let summary = audit_report
311 .issues
312 .iter()
313 .take(8)
314 .map(|issue| format!("{}:{}: {}", issue.path.display(), issue.line, issue.message))
315 .collect::<Vec<_>>()
316 .join("\n");
317 anyhow::bail!("snippet audit failed for crate `{}`:\n{summary}", config.name);
318 }
319 }
320
321 let required_languages = snippet_cfg
322 .required_languages
323 .iter()
324 .map(|lang| lang.parse::<crate::snippets::types::Language>())
325 .collect::<Result<Vec<_>, _>>()
326 .map_err(|err| anyhow::anyhow!("invalid docs.snippets.required_languages entry: {err}"))?;
327
328 if !docs_dirs.is_empty() || !required_languages.is_empty() {
329 let report = crate::snippets::gaps::detect_gaps(&crate::snippets::gaps::GapConfig {
330 docs_dirs,
331 snippet_dirs: absolute_snippet_dirs.to_vec(),
332 required_languages,
333 include_base_paths,
334 })?;
335 if !report.unreferenced_snippets.is_empty() {
336 tracing::warn!(
337 "docs.snippets found {} unreferenced snippet file(s); not failing because extra examples can be intentional",
338 report.unreferenced_snippets.len()
339 );
340 }
341 if !report.missing_references.is_empty()
342 || !report.missing_language_variants.is_empty()
343 || !report.skips_without_reason.is_empty()
344 || !report.unknown_languages.is_empty()
345 {
346 anyhow::bail!("snippet gap validation failed for crate `{}`", config.name);
347 }
348 }
349
350 if let Some(level) = &snippet_cfg.validation_level {
351 let level = level
352 .parse::<crate::snippets::types::ValidationLevel>()
353 .map_err(|err| anyhow::anyhow!("invalid docs.snippets.validation_level: {err}"))?;
354 let mut runner_cfg = crate::snippets::runner::RunnerConfig {
355 level,
356 fail_fast: snippet_cfg.fail_fast,
357 ..crate::snippets::runner::RunnerConfig::default()
358 };
359 if let Some(timeout_secs) = snippet_cfg.timeout_secs {
360 runner_cfg.timeout_secs = timeout_secs;
361 }
362 let registry = crate::snippets::validators::ValidatorRegistry::default();
363 let summary = crate::snippets::runner::run_validation(snippets, ®istry, &runner_cfg)?;
364 if summary.unavailable > 0 {
365 tracing::warn!(
366 "docs.snippets skipped {} snippet validation(s) because required toolchains were unavailable",
367 summary.unavailable
368 );
369 }
370 if summary.has_failures() {
371 anyhow::bail!(
372 "snippet validation failed for crate `{}`: {} failed, {} errors",
373 config.name,
374 summary.failed,
375 summary.errors
376 );
377 }
378 }
379
380 Ok(())
381}
382
383fn docs_sources(config: &ResolvedCrateConfig, configured_sources: &[PathBuf], workspace_root: &Path) -> Vec<PathBuf> {
384 let sources = if configured_sources.is_empty() {
385 config.source_hash_paths()
386 } else {
387 configured_sources.to_vec()
388 };
389 sources
390 .into_iter()
391 .map(|source| {
392 if source.is_absolute() {
393 source
394 } else {
395 workspace_root.join(source)
396 }
397 })
398 .collect()
399}
400
401fn warn_missing_explicit_sources(kind: &str, sources: &[PathBuf], workspace_root: &Path) {
402 let kind = kind.to_ascii_lowercase();
403 for source in sources {
404 if !workspace_root.join(source).exists() {
405 tracing::warn!("docs.{kind} source does not exist, skipping: {}", source.display());
406 }
407 }
408}
409
410fn with_markdown_alef_header(content: &str) -> String {
411 render::with_html_header(content.to_string())
412}