use clap::{Args, Subcommand};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use crate::docs;
use homeboy::component;
use homeboy::docs_audit::{self, AuditResult};
use super::CmdResult;
#[derive(Args)]
pub struct DocsArgs {
#[command(subcommand)]
pub command: Option<DocsCommand>,
#[arg(value_name = "TOPIC")]
pub topic: Option<String>,
}
#[derive(Subcommand)]
pub enum DocsCommand {
Scaffold {
component_id: String,
#[arg(long, default_value = "docs")]
docs_dir: String,
#[arg(long, value_delimiter = ',')]
source_dirs: Option<Vec<String>>,
#[arg(long, value_delimiter = ',')]
source_extensions: Option<Vec<String>>,
#[arg(long)]
detect_by_extension: bool,
},
Audit {
component_id: String,
#[arg(long)]
docs_dir: Option<String>,
},
Generate {
spec: Option<String>,
#[arg(long, value_name = "JSON")]
json: Option<String>,
},
}
#[derive(Serialize)]
pub struct ScaffoldAnalysis {
pub component_id: String,
pub source_directories: Vec<String>,
pub existing_docs: Vec<String>,
pub undocumented: Vec<String>,
}
#[derive(Serialize)]
#[serde(tag = "command")]
pub enum DocsOutput {
#[serde(rename = "docs.scaffold")]
Scaffold {
analysis: ScaffoldAnalysis,
instructions: String,
hints: Vec<String>,
},
#[serde(rename = "docs.audit")]
Audit(AuditResult),
#[serde(rename = "docs.generate")]
Generate {
files_created: Vec<String>,
files_updated: Vec<String>,
hints: Vec<String>,
},
}
#[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 fn is_json_mode(args: &DocsArgs) -> bool {
matches!(
args.command,
Some(DocsCommand::Scaffold { .. })
| Some(DocsCommand::Audit { .. })
| Some(DocsCommand::Generate { .. })
)
}
pub fn run_markdown(args: DocsArgs) -> CmdResult<String> {
let topic = args.topic.as_deref().unwrap_or("index");
if topic == "list" {
let topics = docs::available_topics();
return Ok((topics.join("\n"), 0));
}
let topic_vec = vec![topic.to_string()];
let resolved = docs::resolve(&topic_vec)?;
Ok((resolved.content, 0))
}
pub fn run(args: DocsArgs, _global: &super::GlobalArgs) -> CmdResult<DocsOutput> {
match args.command {
Some(DocsCommand::Scaffold {
component_id,
docs_dir,
source_dirs,
source_extensions,
detect_by_extension,
}) => run_scaffold(
&component_id,
&docs_dir,
source_dirs,
source_extensions,
detect_by_extension,
),
Some(DocsCommand::Audit { component_id, docs_dir }) => run_audit(&component_id, docs_dir.as_deref()),
Some(DocsCommand::Generate { spec, json }) => {
let json_spec = json.as_deref().or(spec.as_deref());
run_generate(json_spec)
}
None => Err(homeboy::Error::validation_invalid_argument(
"command",
"JSON output requires scaffold, audit, or generate subcommand. Use `homeboy docs <topic>` for topic display.",
None,
Some(vec![
"homeboy docs scaffold <component-id>".to_string(),
"homeboy docs audit <component-id>".to_string(),
"homeboy docs generate --json '<spec>'".to_string(),
"homeboy docs commands/deploy".to_string(),
]),
)),
}
}
fn run_scaffold(
component_id: &str,
docs_dir: &str,
explicit_source_dirs: Option<Vec<String>>,
source_extensions: Option<Vec<String>>,
detect_by_extension: bool,
) -> CmdResult<DocsOutput> {
let comp = component::load(component_id)?;
let source_path = Path::new(&comp.local_path);
let docs_path = source_path.join(docs_dir);
let source_directories = if let Some(dirs) = explicit_source_dirs {
dirs
} else if detect_by_extension {
let extensions = source_extensions
.clone()
.unwrap_or_else(default_source_extensions);
find_source_directories_by_extension(source_path, &extensions)
} else if let Some(extensions) = source_extensions {
find_source_directories_by_extension(source_path, &extensions)
} else {
let conventional = find_source_directories(source_path);
if conventional.is_empty() {
let extensions = default_source_extensions();
find_source_directories_by_extension(source_path, &extensions)
} else {
conventional
}
};
let existing_docs = find_existing_docs(&docs_path);
let undocumented = identify_undocumented(&source_directories, &existing_docs, &docs_path);
let mut hints = Vec::new();
hints.push(format!(
"Found {} source directories",
source_directories.len()
));
if !existing_docs.is_empty() {
hints.push(format!("{} docs already exist", existing_docs.len()));
}
if !undocumented.is_empty() {
hints.push(format!(
"{} directories may need documentation",
undocumented.len()
));
}
Ok((
DocsOutput::Scaffold {
analysis: ScaffoldAnalysis {
component_id: component_id.to_string(),
source_directories,
existing_docs,
undocumented,
},
instructions: "Run `homeboy docs documentation/generation` for writing guidelines"
.to_string(),
hints,
},
0,
))
}
fn run_audit(component_id: &str, docs_dir: Option<&str>) -> CmdResult<DocsOutput> {
let result = if std::path::Path::new(component_id).is_dir() {
docs_audit::audit_path(component_id, docs_dir)?
} else {
docs_audit::audit_component(component_id, docs_dir)?
};
Ok((DocsOutput::Audit(result), 0))
}
fn default_source_extensions() -> Vec<String> {
vec![
"php".to_string(),
"rs".to_string(),
"js".to_string(),
"ts".to_string(),
"jsx".to_string(),
"tsx".to_string(),
"py".to_string(),
"go".to_string(),
"java".to_string(),
"rb".to_string(),
"swift".to_string(),
"kt".to_string(),
]
}
fn find_source_directories(source_path: &Path) -> Vec<String> {
let mut dirs = Vec::new();
let source_dir_names = [
"src",
"lib",
"inc",
"app",
"components",
"modules",
"crates",
];
for dir_name in &source_dir_names {
let dir_path = source_path.join(dir_name);
if dir_path.is_dir() {
dirs.push(dir_name.to_string());
if let Ok(entries) = fs::read_dir(&dir_path) {
for entry in entries.flatten() {
if entry.path().is_dir() {
let name = entry.file_name().to_string_lossy().to_string();
if !name.starts_with('.') {
dirs.push(format!("{}/{}", dir_name, name));
}
}
}
}
}
}
dirs.sort();
dirs
}
fn find_source_directories_by_extension(source_path: &Path, extensions: &[String]) -> Vec<String> {
let mut dirs = Vec::new();
if directory_contains_source_files(source_path, extensions) {
dirs.push(".".to_string());
}
if let Ok(entries) = fs::read_dir(source_path) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.')
|| name == "node_modules"
|| name == "vendor"
|| name == "docs"
|| name == "tests"
|| name == "test"
|| name == "__pycache__"
|| name == "target"
|| name == "build"
|| name == "dist"
{
continue;
}
if path.is_dir() && directory_contains_source_files(&path, extensions) {
dirs.push(name.clone());
if let Ok(sub_entries) = fs::read_dir(&path) {
for sub_entry in sub_entries.flatten() {
let sub_path = sub_entry.path();
let sub_name = sub_entry.file_name().to_string_lossy().to_string();
if !sub_name.starts_with('.')
&& sub_path.is_dir()
&& directory_contains_source_files(&sub_path, extensions)
{
dirs.push(format!("{}/{}", name, sub_name));
}
}
}
}
}
}
dirs.sort();
dirs
}
fn directory_contains_source_files(dir: &Path, extensions: &[String]) -> bool {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy().to_lowercase();
if extensions.iter().any(|e| e.to_lowercase() == ext_str) {
return true;
}
}
}
}
}
false
}
fn find_existing_docs(docs_path: &Path) -> Vec<String> {
let mut docs = Vec::new();
if !docs_path.exists() {
return docs;
}
fn scan_docs(dir: &Path, prefix: &str, docs: &mut Vec<String>) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
if path.is_file() && name.ends_with(".md") {
let relative = if prefix.is_empty() {
name
} else {
format!("{}/{}", prefix, name)
};
docs.push(relative);
} else if path.is_dir() {
let new_prefix = if prefix.is_empty() {
name.clone()
} else {
format!("{}/{}", prefix, name)
};
scan_docs(&path, &new_prefix, docs);
}
}
}
}
scan_docs(docs_path, "", &mut docs);
docs.sort();
docs
}
fn identify_undocumented(
source_dirs: &[String],
existing_docs: &[String],
docs_path: &Path,
) -> Vec<String> {
let mut referenced_dirs: std::collections::HashSet<String> = std::collections::HashSet::new();
for doc_file in existing_docs {
let doc_full_path = docs_path.join(doc_file);
if let Ok(content) = fs::read_to_string(&doc_full_path) {
for src_dir in source_dirs {
let dir_name = src_dir.split('/').next_back().unwrap_or(src_dir);
if content.contains(src_dir)
|| content.contains(&format!("`{}`", src_dir))
|| content.contains(&format!("`{}/", src_dir))
|| content.contains(&format!("{}/", src_dir))
{
referenced_dirs.insert(src_dir.clone());
}
if content.contains(&format!("{}/", dir_name))
|| content.contains(&format!("`{}`", dir_name))
{
referenced_dirs.insert(src_dir.clone());
}
}
}
}
source_dirs
.iter()
.filter(|src_dir| {
let dir_name = src_dir.split('/').next_back().unwrap_or(src_dir);
let has_matching_doc = existing_docs
.iter()
.any(|doc| doc.contains(dir_name) || doc.replace(".md", "").contains(dir_name));
let is_referenced = referenced_dirs.contains(*src_dir);
!has_matching_doc && !is_referenced
})
.cloned()
.collect()
}
fn run_generate(json_spec: Option<&str>) -> CmdResult<DocsOutput> {
let spec_str = json_spec.ok_or_else(|| {
homeboy::Error::validation_invalid_argument(
"json",
"Generate requires a JSON spec. Use --json or provide as positional argument.",
None,
Some(vec![
r#"homeboy docs generate --json '{"output_dir":"docs","files":[{"path":"test.md","title":"Test"}]}'"#.to_string(),
]),
)
})?;
let json_content = super::merge_json_sources(Some(spec_str), &[])?;
let spec: GenerateSpec = serde_json::from_value(json_content).map_err(|e| {
homeboy::Error::validation_invalid_json(e, Some("parse generate spec".to_string()), None)
})?;
let output_path = Path::new(&spec.output_dir);
if !output_path.exists() {
fs::create_dir_all(output_path).map_err(|e| {
homeboy::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| {
homeboy::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| {
homeboy::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((
DocsOutput::Generate {
files_created,
files_updated,
hints,
},
0,
))
}
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 + 1) / 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::*;
use std::fs;
fn create_temp_dir() -> tempfile::TempDir {
tempfile::tempdir().expect("Failed to create temp dir")
}
fn write_md(dir: &Path, name: &str, content: &str) {
fs::write(dir.join(name), content).expect("Failed to write test file");
}
#[test]
fn test_infer_sections_returns_none_when_fewer_than_3_siblings() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Config\n\n## Usage\n");
write_md(dir, "b.md", "# B\n\n## Config\n\n## Usage\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_none(), "Should return None with only 2 siblings");
}
#[test]
fn test_infer_sections_finds_common_headings() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Configuration\n\n## Parameters\n\n## Error Handling\n");
write_md(dir, "b.md", "# B\n\n## Configuration\n\n## Parameters\n\n## Error Handling\n");
write_md(dir, "c.md", "# C\n\n## Configuration\n\n## Parameters\n\n## Error Handling\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_some(), "Should find common headings");
let headings = result.unwrap();
assert_eq!(headings, vec!["Configuration", "Parameters", "Error Handling"]);
}
#[test]
fn test_infer_sections_excludes_target_file() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Config\n\n## Usage\n");
write_md(dir, "b.md", "# B\n\n## Config\n\n## Usage\n");
write_md(dir, "c.md", "# C\n\n## Config\n\n## Usage\n");
write_md(dir, "new.md", "# New\n\n## Totally Different\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_some());
let headings = result.unwrap();
assert!(headings.contains(&"Config".to_string()));
assert!(headings.contains(&"Usage".to_string()));
assert!(!headings.contains(&"Totally Different".to_string()));
}
#[test]
fn test_infer_sections_filters_uncommon_headings() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Config\n\n## Usage\n\n## Special A\n");
write_md(dir, "b.md", "# B\n\n## Config\n\n## Usage\n\n## Special B\n");
write_md(dir, "c.md", "# C\n\n## Config\n\n## Usage\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_some());
let headings = result.unwrap();
assert_eq!(headings, vec!["Config", "Usage"]);
}
#[test]
fn test_infer_sections_returns_none_for_nonexistent_dir() {
let result = infer_sections_from_siblings(Path::new("/nonexistent/path"), "new.md");
assert!(result.is_none());
}
#[test]
fn test_infer_sections_skips_non_md_files() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Config\n");
write_md(dir, "b.md", "# B\n\n## Config\n");
write_md(dir, "c.md", "# C\n\n## Config\n");
fs::write(dir.join("readme.txt"), "## Not Markdown\n").unwrap();
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_some());
}
#[test]
fn test_infer_sections_ignores_h3_headings() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Config\n\n### Sub Detail\n");
write_md(dir, "b.md", "# B\n\n## Config\n\n### Sub Detail\n");
write_md(dir, "c.md", "# C\n\n## Config\n\n### Sub Detail\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_some());
let headings = result.unwrap();
assert_eq!(headings, vec!["Config"]);
assert!(!headings.contains(&"Sub Detail".to_string()));
}
#[test]
fn test_infer_sections_returns_none_when_no_common_pattern() {
let tmp = create_temp_dir();
let dir = tmp.path();
write_md(dir, "a.md", "# A\n\n## Alpha\n");
write_md(dir, "b.md", "# B\n\n## Beta\n");
write_md(dir, "c.md", "# C\n\n## Gamma\n");
let result = infer_sections_from_siblings(dir, "new.md");
assert!(result.is_none(), "No heading appears in 3+ files");
}
#[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");
}
}