use crate::handlers;
use crate::parse::WordSet;
pub struct CommandDoc {
pub name: String,
pub kind: DocKind,
pub url: &'static str,
pub description: String,
pub aliases: Vec<String>,
pub category: String,
}
pub enum DocKind {
Handler,
}
impl CommandDoc {
pub fn handler(name: &'static str, url: &'static str, description: impl Into<String>, category: &str) -> Self {
let raw = description.into();
let description = raw
.lines()
.map(|line| {
if line.is_empty() || line.starts_with("- ") {
line.to_string()
} else {
format!("- {line}")
}
})
.collect::<Vec<_>>()
.join("\n");
Self { name: name.to_string(), kind: DocKind::Handler, url, description, aliases: Vec::new(), category: category.to_string() }
}
pub fn wordset(name: &'static str, url: &'static str, words: &WordSet, category: &str) -> Self {
Self::handler(name, url, doc(words).build(), category)
}
pub fn wordset_multi(name: &'static str, url: &'static str, words: &WordSet, multi: &[(&str, WordSet)], category: &str) -> Self {
Self::handler(name, url, doc_multi(words, multi).build(), category)
}
}
#[derive(Default)]
pub struct DocBuilder {
subcommands: Vec<String>,
flags: Vec<String>,
sections: Vec<String>,
}
impl DocBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn wordset(mut self, words: &WordSet) -> Self {
for item in words.iter() {
if item.starts_with('-') {
self.flags.push(item.to_string());
} else {
self.subcommands.push(item.to_string());
}
}
self
}
pub fn multi_word(mut self, multi: &[(&str, WordSet)]) -> Self {
for (prefix, actions) in multi {
for action in actions.iter() {
self.subcommands.push(format!("{prefix} {action}"));
}
}
self
}
pub fn triple_word(mut self, triples: &[(&str, &str, WordSet)]) -> Self {
for (a, b, actions) in triples {
for action in actions.iter() {
self.subcommands.push(format!("{a} {b} {action}"));
}
}
self
}
pub fn subcommand(mut self, name: impl Into<String>) -> Self {
self.subcommands.push(name.into());
self
}
pub fn section(mut self, text: impl Into<String>) -> Self {
let s = text.into();
if !s.is_empty() {
self.sections.push(s);
}
self
}
pub fn build(self) -> String {
let mut lines = Vec::new();
if !self.subcommands.is_empty() {
let mut subs = self.subcommands;
subs.sort();
lines.push(format!("- Subcommands: {}", subs.join(", ")));
}
if !self.flags.is_empty() {
lines.push(format!("- Flags: {}", self.flags.join(", ")));
}
for s in self.sections {
if s.starts_with("- ") {
lines.push(s);
} else {
lines.push(format!("- {s}"));
}
}
lines.join("\n")
}
}
pub fn doc(words: &WordSet) -> DocBuilder {
DocBuilder::new().wordset(words)
}
pub fn doc_multi(words: &WordSet, multi: &[(&str, WordSet)]) -> DocBuilder {
DocBuilder::new().wordset(words).multi_word(multi)
}
pub fn wordset_items(words: &WordSet) -> String {
let items: Vec<&str> = words.iter().collect();
items.join(", ")
}
pub fn all_command_docs() -> Vec<CommandDoc> {
let mut docs = handlers::handler_docs();
docs.sort_by_key(|a| a.name.to_ascii_lowercase());
docs
}
const GLOSSARY: &str = "\
| Term | Meaning |\n\
|------|---------|\n\
| **Allowed standalone flags** | Flags that take no value (`--verbose`, `-v`). Listed on flat commands. |\n\
| **Flags** | Same as standalone flags, but in the shorter format used within subcommand entries. |\n\
| **Allowed valued flags** | Flags that require a value (`--output file`, `-j 4`). |\n\
| **Valued** | Same as valued flags, in shorter format within subcommand entries. |\n\
| **Bare invocation allowed** | The command can be run with no arguments at all. |\n\
| **Subcommands** | Named subcommands that are allowed (e.g. `git log`, `cargo test`). |\n\
| **Positional arguments only** | No specific flags are listed; only positional arguments are accepted. |\n\
| **(requires --flag)** | A guarded subcommand that is only allowed when a specific flag is present (e.g. `cargo fmt` requires `--check`). |\n\
\n\
Unlisted flags, subcommands, and commands are not allowed.\n";
pub fn render_markdown(docs: &[CommandDoc]) -> String {
let mut out = format!(
"# Supported Commands\n\n\
Auto-generated by `safe-chains --list-commands`. These commands, subcommands, and flags are safe to run individually or in combination.\n\n\
## Glossary\n\n{GLOSSARY}\n",
);
for doc in docs {
out.push_str(&render_command_entry(doc));
}
out
}
fn category_display_name(slug: &str) -> &'static str {
match slug {
"ai" => "AI Tools",
"android" => "Android",
"ansible" => "Ansible",
"binary" => "Binary Analysis",
"builtins" => "Shell Builtins",
"cloud" => "Cloud Providers",
"containers" => "Containers",
"data" => "Data Processing",
"dotnet" => ".NET",
"forges" => "Code Forges",
"fs" => "Filesystem",
"fuzzy" => "Fuzzy Finders",
"go" => "Go",
"hash" => "Hashing",
"jvm" => "JVM",
"kafka" => "Kafka",
"magick" => "ImageMagick",
"net" => "Networking",
"node" => "Node.js",
"php" => "PHP",
"pm" => "Package Managers",
"python" => "Python",
"r" => "R",
"ruby" => "Ruby",
"rust" => "Rust",
"search" => "Search",
"swift" => "Swift",
"sysinfo" => "System Info",
"system" => "System",
"text" => "Text Processing",
"tools" => "Developer Tools",
"vcs" => "Version Control",
"wrappers" => "Shell Wrappers",
"xcode" => "Xcode",
other => panic!("unknown category '{other}' — add it to category_display_name() in src/docs.rs")
}
}
fn render_command_entry(doc: &CommandDoc) -> String {
let mut out = String::new();
out.push_str(&format!("### `{}`\n", doc.name));
out.push_str(&format!(
"<p class=\"cmd-url\"><a href=\"{}\">{}</a></p>\n\n",
doc.url, doc.url,
));
if !doc.aliases.is_empty() {
let alias_str: Vec<String> = doc.aliases.iter().map(|a| format!("`{a}`")).collect();
out.push_str(&format!("Aliases: {}\n\n", alias_str.join(", ")));
}
out.push_str(&format!("{}\n\n", doc.description));
out
}
pub fn render_book(docs: &[CommandDoc], output_dir: &std::path::Path) {
use std::collections::BTreeMap;
use std::fs;
let commands_dir = output_dir.join("src").join("commands");
fs::create_dir_all(&commands_dir).expect("failed to create commands dir");
let mut by_category: BTreeMap<&str, Vec<&CommandDoc>> = BTreeMap::new();
for doc in docs {
by_category.entry(&doc.category).or_default().push(doc);
}
let total: usize = by_category.values().map(|v| v.len()).sum();
let mut readme = format!(
"# Command Reference\n\n\
safe-chains knows {total} commands across {} categories.\n\n\
## Glossary\n\n{GLOSSARY}\n",
by_category.len(),
);
for (slug, cmds) in &by_category {
let name = category_display_name(slug);
readme.push_str(&format!(
"- [{}]({}.md) ({} commands)\n",
name, slug, cmds.len(),
));
}
readme.push('\n');
fs::write(commands_dir.join("README.md"), &readme)
.expect("failed to write commands/README.md");
for (slug, cmds) in &by_category {
let name = category_display_name(slug);
let mut page = format!("# {name}\n\n");
for doc in cmds {
page.push_str(&render_command_entry(doc));
}
fs::write(commands_dir.join(format!("{slug}.md")), &page)
.expect("failed to write category page");
}
eprintln!("Generated {} category pages:", by_category.len());
for slug in by_category.keys() {
eprintln!(" - [{}](commands/{}.md)", category_display_name(slug), slug);
}
}
pub fn render_opencode_json(patterns: &[String]) -> String {
use serde_json::{Map, Value};
use std::fs;
let mut root: Map<String, Value> = fs::read_to_string("opencode.json")
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.and_then(|v: Value| v.as_object().cloned())
.unwrap_or_else(|| {
let mut m = Map::new();
m.insert(
"$schema".to_string(),
Value::String("https://opencode.ai/config.json".to_string()),
);
m
});
let mut bash = Map::new();
bash.insert("*".to_string(), Value::String("ask".to_string()));
for pat in patterns {
bash.insert(pat.clone(), Value::String("allow".to_string()));
}
let permission = root
.entry("permission")
.or_insert_with(|| Value::Object(Map::new()));
if !permission.is_object() {
*permission = Value::Object(Map::new());
}
if let Value::Object(perm_map) = permission {
perm_map.insert("bash".to_string(), Value::Object(bash));
}
let mut out = serde_json::to_string_pretty(&Value::Object(root)).unwrap_or_default();
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn all_commands_have_url() {
for doc in all_command_docs() {
assert!(!doc.url.is_empty(), "{} has no documentation URL", doc.name);
assert!(
doc.url.starts_with("https://"),
"{} URL must use https: {}",
doc.name,
doc.url
);
}
}
#[test]
fn all_commands_have_valid_category() {
for doc in all_command_docs() {
assert!(!doc.category.is_empty(), "{} has no category", doc.name);
category_display_name(&doc.category);
}
}
#[test]
fn builder_two_sections() {
let ws = WordSet::new(&["--version", "list", "show"]);
assert_eq!(doc(&ws).build(), "- Subcommands: list, show\n- Flags: --version");
}
#[test]
fn builder_subcommands_only() {
let ws = WordSet::new(&["list", "show"]);
assert_eq!(doc(&ws).build(), "- Subcommands: list, show");
}
#[test]
fn builder_flags_only() {
let ws = WordSet::new(&["--check", "--version"]);
assert_eq!(doc(&ws).build(), "- Flags: --check, --version");
}
#[test]
fn builder_three_sections() {
let ws = WordSet::new(&["--version", "list", "show"]);
assert_eq!(
doc(&ws).section("Guarded: foo (bar only).").build(),
"- Subcommands: list, show\n- Flags: --version\n- Guarded: foo (bar only)."
);
}
#[test]
fn builder_multi_word_merged() {
let ws = WordSet::new(&["--version", "info", "show"]);
let multi: &[(&str, WordSet)] =
&[("config", WordSet::new(&["get", "list"]))];
assert_eq!(
doc_multi(&ws, multi).build(),
"- Subcommands: config get, config list, info, show\n- Flags: --version"
);
}
#[test]
fn builder_multi_word_with_extra_section() {
let ws = WordSet::new(&["--version", "show"]);
let multi: &[(&str, WordSet)] =
&[("config", WordSet::new(&["get", "list"]))];
assert_eq!(
doc_multi(&ws, multi).section("Guarded: foo.").build(),
"- Subcommands: config get, config list, show\n- Flags: --version\n- Guarded: foo."
);
}
#[test]
fn builder_no_flags_with_extra() {
let ws = WordSet::new(&["list", "show"]);
assert_eq!(
doc(&ws).section("Also: foo.").build(),
"- Subcommands: list, show\n- Also: foo."
);
}
#[test]
fn builder_custom_sections_only() {
assert_eq!(
DocBuilder::new()
.section("Read-only: foo.")
.section("Always safe: bar.")
.section("Guarded: baz.")
.build(),
"- Read-only: foo.\n- Always safe: bar.\n- Guarded: baz."
);
}
#[test]
fn builder_triple_word() {
let ws = WordSet::new(&["--version", "diff"]);
let triples: &[(&str, &str, WordSet)] =
&[("git", "remote", WordSet::new(&["list"]))];
assert_eq!(
doc(&ws).triple_word(triples).build(),
"- Subcommands: diff, git remote list\n- Flags: --version"
);
}
#[test]
fn builder_subcommand_method() {
let ws = WordSet::new(&["--version", "list"]);
assert_eq!(
doc(&ws).subcommand("plugin-list").build(),
"- Subcommands: list, plugin-list\n- Flags: --version"
);
}
#[test]
fn render_opencode_json_valid() {
let patterns = vec!["grep".to_string(), "grep *".to_string(), "ls".to_string()];
let json = render_opencode_json(&patterns);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
let bash = &parsed["permission"]["bash"];
assert_eq!(bash["*"], "ask");
assert_eq!(bash["grep"], "allow");
assert_eq!(bash["grep *"], "allow");
assert_eq!(bash["ls"], "allow");
assert!(bash["rm"].is_null());
}
#[test]
fn render_opencode_json_has_schema() {
let json = render_opencode_json(&[]);
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["$schema"], "https://opencode.ai/config.json");
}
#[test]
fn render_opencode_json_trailing_newline() {
let json = render_opencode_json(&[]);
assert!(json.ends_with('\n'));
}
#[test]
fn render_opencode_json_merges_existing() {
use std::fs;
let dir = tempfile::tempdir().expect("tmpdir");
let config_path = dir.path().join("opencode.json");
fs::write(
&config_path,
r#"{"$schema":"https://opencode.ai/config.json","model":"claude-sonnet-4-6","permission":{"bash":{"rm *":"deny"}}}"#,
)
.expect("write");
let prev = std::env::current_dir().expect("cwd");
std::env::set_current_dir(dir.path()).expect("cd");
let json = render_opencode_json(&["ls".to_string()]);
std::env::set_current_dir(prev).expect("cd back");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
assert_eq!(parsed["model"], "claude-sonnet-4-6", "existing keys preserved");
assert_eq!(parsed["permission"]["bash"]["*"], "ask");
assert_eq!(parsed["permission"]["bash"]["ls"], "allow");
assert!(
parsed["permission"]["bash"]["rm *"].is_null(),
"old bash rules replaced, not merged"
);
}
}