use crate::core::backend::GeneratedFile;
use crate::core::config::{Language, ResolvedCrateConfig};
use crate::core::ir::ApiSurface;
use std::collections::HashSet;
use std::path::PathBuf;
mod fallback;
mod paths;
mod template;
mod template_env;
#[cfg(test)]
mod tests;
pub fn generate_readmes(
api: &ApiSurface,
config: &ResolvedCrateConfig,
languages: &[Language],
) -> anyhow::Result<Vec<GeneratedFile>> {
validate_readme_snippets_dir(config)?;
let mut files = vec![];
let mut seen_paths = HashSet::new();
for &lang in languages {
if let Some(file) = generate_readme(api, config, lang)? {
push_unique_readme(&mut files, &mut seen_paths, file)?;
}
}
for file in generate_readme_targets(api, config)? {
push_unique_readme(&mut files, &mut seen_paths, file)?;
}
Ok(files)
}
pub fn expand_configured_readme_languages(config: &ResolvedCrateConfig, languages: &[Language]) -> Vec<Language> {
let mut expanded = languages.to_vec();
if rust_readme_explicitly_configured(config) && !expanded.contains(&Language::Rust) {
expanded.push(Language::Rust);
}
expanded
}
fn generate_readme(
api: &ApiSurface,
config: &ResolvedCrateConfig,
lang: Language,
) -> anyhow::Result<Option<GeneratedFile>> {
if matches!(lang, Language::Rust) && !rust_readme_explicitly_configured(config) {
return Ok(None);
}
if matches!(lang, Language::C | Language::Jni) {
return Ok(None);
}
if let Some(file) = try_render_configured_readme(api, config, lang)? {
return Ok(Some(file));
}
let lang_code = paths::lang_code(lang);
if readme_language_declares_a_template(config, lang_code) {
anyhow::bail!(
"crates.readme.languages.{lang_code} names a `template`, but README generation did not render it -- \
refusing to silently substitute the generic placeholder README and discard the configured content \
(#555). Fix the template, `crates.readme.template_dir`, or the `crates.readme.languages.{lang_code}` \
entry so it renders, or drop the entry's `template` key if a generic placeholder README is intended."
);
}
Ok(Some(fallback::generate_readme_hardcoded(api, config, lang)?))
}
fn readme_language_declares_a_template(config: &ResolvedCrateConfig, lang_code: &str) -> bool {
config.readme.as_ref().is_some_and(|readme_cfg| {
readme_cfg
.languages
.get(lang_code)
.and_then(|entry| entry.get("template"))
.and_then(serde_json::Value::as_str)
.is_some_and(|template| !template.trim().is_empty())
})
}
fn try_render_configured_readme(
api: &ApiSurface,
config: &ResolvedCrateConfig,
lang: Language,
) -> anyhow::Result<Option<GeneratedFile>> {
let Some(readme_cfg) = &config.readme else {
return Ok(None);
};
let Some(template_dir) = &readme_cfg.template_dir else {
return Ok(None);
};
let workspace_root = config.workspace_root.clone().unwrap_or_else(|| PathBuf::from("."));
let abs_template_dir = workspace_root.join(template_dir);
if !abs_template_dir.exists() {
return Ok(None);
}
template::try_template_readme(api, config, lang, readme_cfg, &workspace_root, &abs_template_dir)
}
fn generate_readme_targets(api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
let Some(readme_cfg) = &config.readme else {
return Ok(Vec::new());
};
if readme_cfg.targets.is_empty() {
return Ok(Vec::new());
}
let workspace_root = config.workspace_root.clone().unwrap_or_else(|| PathBuf::from("."));
let Some(template_dir) = &readme_cfg.template_dir else {
anyhow::bail!("README targets require `crates.readme.template_dir`");
};
let abs_template_dir = workspace_root.join(template_dir);
if !abs_template_dir.exists() {
anyhow::bail!(
"README template directory '{}' does not exist",
abs_template_dir.display()
);
}
let mut target_names = readme_cfg.targets.keys().cloned().collect::<Vec<_>>();
target_names.sort();
target_names
.into_iter()
.map(|target_name| {
let target_json = readme_cfg
.targets
.get(&target_name)
.ok_or_else(|| anyhow::anyhow!("README target '{target_name}' disappeared during generation"))?;
template::render_target_readme(
api,
config,
&target_name,
target_json,
readme_cfg,
&workspace_root,
&abs_template_dir,
)
})
.collect()
}
fn push_unique_readme(
files: &mut Vec<GeneratedFile>,
seen_paths: &mut HashSet<PathBuf>,
file: GeneratedFile,
) -> anyhow::Result<()> {
if !seen_paths.insert(file.path.clone()) {
anyhow::bail!(
"duplicate README output path '{}'; configure unique `output_path` values",
file.path.display()
);
}
let empty_headings = find_empty_headings(&file.content);
if !empty_headings.is_empty() {
tracing::warn!(
path = %file.path.display(),
headings = %empty_headings.join(", "),
"README emits heading(s) with no body before the next same-or-shallower heading; \
this renders a dangling entry in the page and its TOC -- populate the section, or \
make the template omit the heading when the section has no content"
);
}
files.push(file);
Ok(())
}
fn heading_level(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
let level = trimmed.chars().take_while(|&c| c == '#').count();
if level == 0 || level > 6 {
return None;
}
let rest = &trimmed[level..];
(rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t')).then_some(level)
}
fn find_empty_headings(content: &str) -> Vec<String> {
let lines: Vec<&str> = content.lines().collect();
let mut in_code_block = false;
let is_code: Vec<bool> = lines
.iter()
.map(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_code_block = !in_code_block;
true
} else {
in_code_block
}
})
.collect();
let mut empties = Vec::new();
for (i, line) in lines.iter().enumerate() {
if is_code[i] {
continue;
}
let Some(level) = heading_level(line) else {
continue;
};
let mut has_content = false;
for (offset, next_line) in lines.iter().enumerate().skip(i + 1) {
if !is_code[offset]
&& let Some(next_level) = heading_level(next_line)
{
has_content = next_level > level;
break;
}
if !next_line.trim().is_empty() {
has_content = true;
break;
}
}
if !has_content {
empties.push(line.trim().to_string());
}
}
empties
}
fn validate_readme_snippets_dir(config: &ResolvedCrateConfig) -> anyhow::Result<()> {
let Some(readme_cfg) = &config.readme else {
return Ok(());
};
let workspace_root = config.workspace_root.clone().unwrap_or_else(|| PathBuf::from("."));
let mut roots = readme_cfg
.snippets_dir
.iter()
.map(|root| ("crates.readme.snippets_dir".to_string(), root.clone()))
.collect::<Vec<_>>();
roots.extend(readme_cfg.languages.iter().filter_map(|(language, entry)| {
entry
.get("snippets_dir")
.and_then(serde_json::Value::as_str)
.map(|root| {
(
format!("crates.readme.languages.{language}.snippets_dir"),
PathBuf::from(root),
)
})
}));
for (key, snippets_dir) in roots {
let abs_snippets_dir = workspace_root.join(&snippets_dir);
if !abs_snippets_dir.exists() {
anyhow::bail!(
"config key `{key}` is set to '{}' (resolved to '{}'), which does not exist",
snippets_dir.display(),
abs_snippets_dir.display()
);
}
}
Ok(())
}
fn rust_readme_explicitly_configured(config: &ResolvedCrateConfig) -> bool {
let Some(readme_cfg) = &config.readme else {
return false;
};
let Some(rust_cfg) = readme_cfg.languages.get("rust") else {
return false;
};
rust_cfg
.get("output_path")
.or_else(|| rust_cfg.get("output"))
.and_then(|v| v.as_str())
.is_some()
}