use std::collections::HashMap;
use std::fs;
use std::path::Path;
use serde::Deserialize;
use crate::code_audit::docs_audit::{AuditResult, DetectedFeature};
use crate::{component, extension, Error};
#[derive(Deserialize)]
pub struct GenerateSpec {
pub output_dir: String,
pub files: Vec<GenerateFileSpec>,
}
#[derive(Deserialize)]
pub struct GenerateFileSpec {
pub path: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub content: Option<String>,
}
pub struct GenerateResult {
pub files_created: Vec<String>,
pub files_updated: Vec<String>,
pub hints: Vec<String>,
}
pub fn generate_from_spec(spec: &GenerateSpec) -> Result<GenerateResult, Error> {
let output_path = Path::new(&spec.output_dir);
if !output_path.exists() {
fs::create_dir_all(output_path).map_err(|e| {
Error::internal_io(e.to_string(), Some(format!("create {}", spec.output_dir)))
})?;
}
let mut files_created = Vec::new();
let mut files_updated = Vec::new();
for file_spec in &spec.files {
let file_path = output_path.join(&file_spec.path);
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
Error::internal_io(e.to_string(), Some(format!("create {}", parent.display())))
})?;
}
}
let content = if let Some(ref c) = file_spec.content {
c.clone()
} else {
let title_line = if let Some(ref title) = file_spec.title {
format!("# {}", title)
} else {
let name = file_spec
.path
.trim_end_matches(".md")
.split('/')
.next_back()
.unwrap_or(&file_spec.path);
format!("# {}", title_from_name(name))
};
let filename = file_spec
.path
.split('/')
.next_back()
.unwrap_or(&file_spec.path);
let sibling_dir = if let Some(parent) = file_path.parent() {
parent.to_path_buf()
} else {
output_path.to_path_buf()
};
let sections = infer_sections_from_siblings(&sibling_dir, filename);
if let Some(headings) = sections {
let mut parts = vec![title_line, String::new()];
for heading in headings {
parts.push(format!("## {}", heading));
parts.push(String::new());
}
parts.join("\n")
} else {
format!("{}\n", title_line)
}
};
let existed = file_path.exists();
fs::write(&file_path, &content).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("write {}", file_path.display())),
)
})?;
let relative_path = file_path.to_string_lossy().to_string();
if existed {
files_updated.push(relative_path);
} else {
files_created.push(relative_path);
}
}
let mut hints = Vec::new();
if !files_created.is_empty() {
hints.push(format!("Created {} files", files_created.len()));
}
if !files_updated.is_empty() {
hints.push(format!("Updated {} files", files_updated.len()));
}
Ok(GenerateResult {
files_created,
files_updated,
hints,
})
}
pub fn generate_from_audit(audit: &AuditResult, dry_run: bool) -> Result<GenerateResult, Error> {
if audit.detected_features.is_empty() {
return Err(Error::validation_invalid_argument(
"from-audit",
"Audit result has no detected_features. Use `homeboy audit` to generate audit output with features.",
None,
Some(vec![
"homeboy audit <component-id> > audit.json".to_string(),
"homeboy docs generate --from-audit @audit.json".to_string(),
]),
));
}
let comp = component::load(&audit.component_id).ok();
let (feature_labels, doc_targets) = collect_extension_doc_config(comp.as_ref());
let groups = group_features_by_label(&audit.detected_features, &feature_labels);
let docs_dir = comp
.as_ref()
.and_then(|c| c.docs_dir.as_deref())
.unwrap_or("docs");
let source_path = comp
.as_ref()
.map(|c| Path::new(&c.local_path).to_path_buf())
.unwrap_or_else(|| Path::new(".").to_path_buf());
let docs_path = source_path.join(docs_dir);
let mut files_created = Vec::new();
let mut files_updated = Vec::new();
let mut hints = Vec::new();
for (label, features) in &groups {
let target = match doc_targets.get(label.as_str()) {
Some(t) => t,
None => {
hints.push(format!(
"Skipped '{}' ({} features) \u{2014} no doc_target configured in extension",
label,
features.len()
));
continue;
}
};
let file_path = docs_path.join(&target.file);
let default_heading = format!("## {}", label);
let heading = target.heading.as_deref().unwrap_or(&default_heading);
let template = target
.template
.as_deref()
.unwrap_or("- `{name}` ({source_file}:{line})");
let mut section_lines: Vec<String> = Vec::new();
section_lines.push(heading.to_string());
section_lines.push(String::new());
for feature in features {
let desc = feature.description.as_deref().unwrap_or("");
let has_fields = template.contains("{fields}") && feature.fields.is_some();
let line = template
.replace("{name}", &feature.name)
.replace("{source_file}", &feature.source_file)
.replace("{line}", &feature.line.to_string())
.replace("{description}", desc)
.replace("{fields}", "")
.replace(
"{documented}",
if feature.documented {
"yes"
} else {
"**undocumented**"
},
);
for tpl_line in line.lines() {
if tpl_line.trim().is_empty() {
continue;
}
section_lines.push(tpl_line.to_string());
}
if has_fields {
section_lines.push(String::new());
for field in feature.fields.as_ref().unwrap() {
let field_desc = field.description.as_deref().unwrap_or("");
if field_desc.is_empty() {
section_lines.push(format!("- `{}`", field.name));
} else {
section_lines.push(format!("- `{}` \u{2014} {}", field.name, field_desc));
}
}
}
section_lines.push(String::new());
}
section_lines.push(String::new());
let section_content = section_lines.join("\n");
let existed = file_path.exists();
let final_content = if existed {
let existing = fs::read_to_string(&file_path).unwrap_or_default();
replace_or_append_section(&existing, heading, §ion_content)
} else {
let title = format!("# {}\n\n", label);
format!("{}{}", title, section_content)
};
if !dry_run {
if let Some(parent) = file_path.parent() {
if !parent.exists() {
fs::create_dir_all(parent).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("create {}", parent.display())),
)
})?;
}
}
fs::write(&file_path, &final_content).map_err(|e| {
Error::internal_io(
e.to_string(),
Some(format!("write {}", file_path.display())),
)
})?;
}
let relative = format!("{}/{}", docs_dir, target.file);
if existed {
files_updated.push(relative);
} else {
files_created.push(relative);
}
}
if dry_run {
hints.insert(0, "Dry run \u{2014} no files written".to_string());
}
let mut seen = std::collections::HashSet::new();
files_created.retain(|f| seen.insert(f.clone()));
seen.clear();
files_updated.retain(|f| seen.insert(f.clone()));
files_updated.retain(|f| !files_created.contains(f));
Ok(GenerateResult {
files_created,
files_updated,
hints,
})
}
fn collect_extension_doc_config(
comp: Option<&component::Component>,
) -> (
HashMap<String, String>,
HashMap<String, extension::DocTarget>,
) {
let mut labels = HashMap::new();
let mut targets = HashMap::new();
if let Some(comp) = comp {
if let Some(ref extensions) = comp.extensions {
for extension_id in extensions.keys() {
if let Ok(manifest) = extension::load_extension(extension_id) {
for (key, label) in manifest.audit_feature_labels() {
labels.insert(key.clone(), label.clone());
}
for (label, target) in manifest.audit_doc_targets() {
targets.insert(label.clone(), target.clone());
}
}
}
}
}
(labels, targets)
}
fn group_features_by_label<'a>(
features: &'a [DetectedFeature],
feature_labels: &HashMap<String, String>,
) -> Vec<(String, Vec<&'a DetectedFeature>)> {
let mut groups: HashMap<String, Vec<&'a DetectedFeature>> = HashMap::new();
for feature in features {
let label = feature_labels
.iter()
.find(|(key, _)| feature.pattern.contains(key.as_str()))
.map(|(_, label)| label.clone())
.unwrap_or_else(|| feature.pattern.clone());
groups.entry(label).or_default().push(feature);
}
let mut sorted: Vec<(String, Vec<&DetectedFeature>)> = groups.into_iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
sorted
}
fn replace_or_append_section(existing: &str, heading: &str, new_section: &str) -> String {
let heading_level = heading.chars().take_while(|c| *c == '#').count();
let lines: Vec<&str> = existing.lines().collect();
let start = lines.iter().position(|line| line.trim() == heading);
if let Some(start_idx) = start {
let end_idx = lines[start_idx + 1..]
.iter()
.position(|line| {
let trimmed = line.trim();
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|c| *c == '#').count();
level <= heading_level
} else {
false
}
})
.map(|i| start_idx + 1 + i)
.unwrap_or(lines.len());
let mut result: Vec<&str> = Vec::new();
result.extend_from_slice(&lines[..start_idx]);
let new_lines: Vec<&str> = new_section.lines().collect();
result.extend(new_lines);
if end_idx < lines.len() {
result.extend_from_slice(&lines[end_idx..]);
}
result.join("\n")
} else {
let mut result = existing.to_string();
if !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
result.push_str(new_section);
result
}
}
fn infer_sections_from_siblings(dir: &Path, exclude_filename: &str) -> Option<Vec<String>> {
if !dir.is_dir() {
return None;
}
let entries = fs::read_dir(dir).ok()?;
let mut sibling_headings: Vec<Vec<String>> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if !name.ends_with(".md") || name == exclude_filename || !path.is_file() {
continue;
}
let content = fs::read_to_string(&path).ok();
if let Some(text) = content {
let headings: Vec<String> = text
.lines()
.filter_map(|line| {
let trimmed = line.trim();
if trimmed.starts_with("## ") && !trimmed.starts_with("### ") {
Some(trimmed.trim_start_matches("## ").trim().to_string())
} else {
None
}
})
.collect();
if !headings.is_empty() {
sibling_headings.push(headings);
}
}
}
let sibling_count = sibling_headings.len();
if sibling_count < 3 {
return None;
}
let mut heading_counts: HashMap<String, usize> = HashMap::new();
for headings in &sibling_headings {
let unique: std::collections::HashSet<&String> = headings.iter().collect();
for heading in unique {
*heading_counts.entry(heading.clone()).or_insert(0) += 1;
}
}
let threshold = std::cmp::min(3, sibling_count.div_ceil(2));
let common_set: std::collections::HashSet<&str> = heading_counts
.iter()
.filter(|(_, count)| **count >= threshold)
.map(|(name, _)| name.as_str())
.collect();
if common_set.is_empty() {
return None;
}
let mut median_positions: HashMap<&str, usize> = HashMap::new();
for heading in &common_set {
let mut positions: Vec<usize> = Vec::new();
for headings in &sibling_headings {
if let Some(pos) = headings.iter().position(|h| h == heading) {
positions.push(pos);
}
}
positions.sort();
let median = positions[positions.len() / 2];
median_positions.insert(heading, median);
}
let mut common_headings: Vec<String> = common_set.iter().map(|s| s.to_string()).collect();
common_headings.sort_by_key(|h| {
median_positions
.get(h.as_str())
.copied()
.unwrap_or(usize::MAX)
});
if common_headings.is_empty() {
None
} else {
Some(common_headings)
}
}
fn title_from_name(name: &str) -> String {
name.split(['-', '_'])
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn create_temp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("Failed to create temp dir")
}
#[test]
fn test_title_from_name_kebab_case() {
assert_eq!(title_from_name("google-analytics"), "Google Analytics");
}
#[test]
fn test_title_from_name_snake_case() {
assert_eq!(title_from_name("page_speed"), "Page Speed");
}
}