use destructive_command_guard::packs::{Pack, PackRegistry};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
fn repo_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf()
}
fn category_from_pack_id(pack_id: &str) -> &str {
pack_id.split('.').next().unwrap_or(pack_id)
}
fn category_filename(category: &str) -> String {
format!("{category}.md")
}
fn generate_pack_section(pack: &Pack) -> String {
let mut out = String::new();
let _ = writeln!(out, "## {}\n", pack.name);
let _ = writeln!(out, "**Pack ID:** `{}`\n", pack.id);
let _ = writeln!(out, "{}\n", pack.description);
if !pack.keywords.is_empty() {
out.push_str("### Keywords\n\n");
out.push_str("Commands containing these keywords are checked against this pack:\n\n");
for kw in pack.keywords {
let _ = writeln!(out, "- `{kw}`");
}
out.push('\n');
}
if !pack.safe_patterns.is_empty() {
out.push_str("### Safe Patterns (Allowed)\n\n");
out.push_str("These patterns match safe commands that are always allowed:\n\n");
out.push_str("| Pattern Name | Pattern |\n");
out.push_str("|--------------|----------|\n");
for p in &pack.safe_patterns {
let pattern_str = p.regex.as_str();
let escaped = pattern_str.replace('|', "\\|");
let _ = writeln!(out, "| `{}` | `{escaped}` |", p.name);
}
out.push('\n');
}
if !pack.destructive_patterns.is_empty() {
out.push_str("### Destructive Patterns (Blocked)\n\n");
out.push_str("These patterns match potentially destructive commands:\n\n");
out.push_str("| Pattern Name | Reason | Severity |\n");
out.push_str("|--------------|--------|----------|\n");
for p in &pack.destructive_patterns {
let name = p.name.unwrap_or("(unnamed)");
let severity = p.severity.label();
let reason = p.reason.replace('|', "\\|");
let _ = writeln!(out, "| `{name}` | {reason} | {severity} |");
}
out.push('\n');
}
out.push_str("### Allowlist Guidance\n\n");
out.push_str("To allowlist a specific rule from this pack, add to your allowlist:\n\n");
out.push_str("```toml\n");
out.push_str("[[allow]]\n");
let _ = writeln!(out, "rule = \"{}:<pattern-name>\"", pack.id);
out.push_str("reason = \"Your reason here\"\n");
out.push_str("```\n\n");
out.push_str("To allowlist all rules from this pack (use with caution):\n\n");
out.push_str("```toml\n");
out.push_str("[[allow]]\n");
let _ = writeln!(out, "rule = \"{}:*\"", pack.id);
out.push_str("reason = \"Your reason here\"\n");
out.push_str("risk_acknowledged = true\n");
out.push_str("```\n\n");
out.push_str("---\n\n");
out
}
fn generate_category_doc(category: &str, packs: &[&Pack]) -> String {
let mut out = String::new();
let category_title = match category {
"core" => "Core Packs",
"storage" => "Storage Packs",
"remote" => "Remote Access Packs",
"cicd" => "CI/CD Packs",
"secrets" => "Secrets Management Packs",
"platform" => "Platform Packs",
"dns" => "DNS Packs",
"email" => "Email Packs",
"featureflags" => "Feature Flags Packs",
"loadbalancer" => "Load Balancer Packs",
"monitoring" => "Monitoring Packs",
"payment" => "Payment Packs",
"messaging" => "Messaging Packs",
"search" => "Search Packs",
"backup" => "Backup Packs",
"database" => "Database Packs",
"containers" => "Container Packs",
"kubernetes" => "Kubernetes Packs",
"cloud" => "Cloud Provider Packs",
"cdn" => "CDN Packs",
"apigateway" => "API Gateway Packs",
"infrastructure" => "Infrastructure as Code Packs",
"system" => "System Packs",
"safe" => "Safe Packs",
"strict_git" => "Strict Git Packs",
"package_managers" => "Package Manager Packs",
_ => category,
};
let _ = writeln!(out, "# {category_title}\n");
let _ = writeln!(
out,
"This document describes packs in the `{category}` category.\n"
);
out.push_str("## Packs in this Category\n\n");
for pack in packs {
let anchor = pack.id.replace('.', "");
let _ = writeln!(out, "- [{}](#{anchor})", pack.name);
}
out.push_str("\n---\n\n");
for pack in packs {
out.push_str(&generate_pack_section(pack));
}
out
}
fn generate_all_docs() -> std::io::Result<()> {
let registry = PackRegistry::new();
let docs_packs_dir = repo_root().join("docs/packs");
fs::create_dir_all(&docs_packs_dir)?;
let mut by_category: BTreeMap<String, Vec<&Pack>> = BTreeMap::new();
for pack_id in registry.all_pack_ids() {
let category = category_from_pack_id(pack_id).to_string();
let pack = registry.get(pack_id).expect("pack should exist");
by_category.entry(category).or_default().push(pack);
}
for (category, packs) in &by_category {
let content = generate_category_doc(category, packs);
let filename = category_filename(category);
let path = docs_packs_dir.join(&filename);
fs::write(&path, &content)?;
println!("Generated: docs/packs/{filename}");
}
let mut index = String::new();
index.push_str("# Pack Reference Documentation\n\n");
index.push_str(
"This directory contains detailed reference documentation for all dcg packs.\n\n",
);
index.push_str("## Quick Start\n\n");
index.push_str("Enable packs in `~/.config/dcg/config.toml`:\n\n");
index.push_str("```toml\n");
index.push_str("[packs]\n");
index.push_str("enabled = [\"kubernetes\", \"database\", \"containers\"]\n");
index.push_str("```\n\n");
index.push_str("## Categories\n\n");
index.push_str("| Category | Packs | Description |\n");
index.push_str("|----------|-------|-------------|\n");
for (category, packs) in &by_category {
let pack_names: Vec<&str> = packs.iter().map(|p| p.name).collect();
let pack_count = packs.len();
let first_packs: String = pack_names
.iter()
.take(3)
.copied()
.collect::<Vec<_>>()
.join(", ");
let suffix = if pack_count > 3 { ", ..." } else { "" };
let filename = category_filename(category);
let _ = writeln!(
index,
"| [{category}]({filename}) | {pack_count} | {first_packs}{suffix} |"
);
}
index.push_str("\n## All Pack IDs\n\n");
for pack_id in registry.all_pack_ids() {
let category = category_from_pack_id(pack_id);
let filename = category_filename(category);
let anchor = pack_id.replace('.', "");
let _ = writeln!(index, "- [`{pack_id}`]({filename}#{anchor})");
}
index.push_str("\n## Notes\n\n");
index.push_str("- Enable a whole category by specifying its prefix (e.g., `kubernetes`).\n");
index.push_str(
"- Heredoc/inline-script scanning is configured under `[heredoc]`, not `[packs]`.\n",
);
index.push_str("- See `docs/configuration.md` for full configuration details.\n");
index.push_str("\n---\n\n");
index.push_str("*This documentation is auto-generated from PackRegistry metadata.*\n");
fs::write(docs_packs_dir.join("README.md"), &index)?;
println!("Generated: docs/packs/README.md");
Ok(())
}
#[test]
fn verify_all_packs_have_documentation() -> std::io::Result<()> {
let registry = PackRegistry::new();
let docs_packs_dir = repo_root().join("docs/packs");
let mut missing: Vec<String> = Vec::new();
for pack_id in registry.all_pack_ids() {
let category = category_from_pack_id(pack_id);
let doc_path = docs_packs_dir.join(category_filename(category));
if !doc_path.exists() {
missing.push(format!(
"Category doc missing for pack '{pack_id}': expected {}",
doc_path.display()
));
continue;
}
let content = fs::read_to_string(&doc_path)?;
if !content.contains(&format!("**Pack ID:** `{pack_id}`")) {
missing.push(format!(
"Pack '{pack_id}' not documented in {}",
doc_path.display()
));
}
}
assert!(
missing.is_empty(),
"Documentation coverage issues:\n{}",
missing.join("\n")
);
Ok(())
}
#[test]
#[ignore = "Run with `cargo test generate_pack_docs -- --ignored` to regenerate"]
fn regenerate_pack_docs() -> std::io::Result<()> {
generate_all_docs()
}