use kaish_types::{Example, ParamSchema, ToolSchema};
use crate::compose::render_syntax_section;
use crate::content::{IGNORE, LIMITS, OUTPUT_LIMIT, OVERLAY, OVERVIEW, SCATTER, SYNTAX, VFS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HelpTopic {
Overview,
Syntax,
Builtins,
Vfs,
Scatter,
Ignore,
OutputLimit,
Limits,
Overlay,
SyntaxSection(String),
Tool(String),
}
impl HelpTopic {
pub fn parse_topic(s: &str) -> Self {
match s.to_lowercase().as_str() {
"" | "overview" | "help" => Self::Overview,
"syntax" | "language" | "lang" => Self::Syntax,
"builtins" | "tools" | "commands" => Self::Builtins,
"vfs" | "filesystem" | "fs" | "paths" => Self::Vfs,
"scatter" | "gather" | "parallel" | "散" | "集" => Self::Scatter,
"ignore" | "gitignore" | "kaish-ignore" => Self::Ignore,
"output-limit" | "spill" | "truncate" | "kaish-output-limit" => Self::OutputLimit,
"limits" | "limitations" | "missing" => Self::Limits,
"overlay" | "kaish-vfs" | "vfs-overlay" => Self::Overlay,
other if render_syntax_section(other).is_some() => Self::SyntaxSection(other.to_string()),
other => Self::Tool(other.to_string()),
}
}
pub fn description(&self) -> &'static str {
match self {
Self::Overview => "What kaish is, list of topics",
Self::Syntax => "Variables, quoting, pipes, control flow",
Self::Builtins => "List of available builtins",
Self::Vfs => "Virtual filesystem mounts and paths",
Self::Scatter => "Parallel processing (散/集)",
Self::Ignore => "Ignore file configuration",
Self::OutputLimit => "Output size limit configuration",
Self::Limits => "Known limitations",
Self::Overlay => "Copy-on-write overlay mode and kaish-vfs",
Self::SyntaxSection(_) => "A single syntax reference section",
Self::Tool(_) => "Help for a specific tool",
}
}
}
pub fn get_help(topic: &HelpTopic, tool_schemas: &[ToolSchema]) -> String {
match topic {
HelpTopic::Overview => OVERVIEW.to_string(),
HelpTopic::Syntax => SYNTAX.to_string(),
HelpTopic::Builtins => format_tool_list(tool_schemas),
HelpTopic::Vfs => VFS.to_string(),
HelpTopic::Scatter => SCATTER.to_string(),
HelpTopic::Ignore => IGNORE.to_string(),
HelpTopic::OutputLimit => OUTPUT_LIMIT.to_string(),
HelpTopic::Limits => LIMITS.to_string(),
HelpTopic::Overlay => OVERLAY.to_string(),
HelpTopic::SyntaxSection(key) => render_syntax_section(key).unwrap_or_else(|| {
format!(
"Unknown topic or tool: {key}\n\nUse 'help' to see available topics, or 'help builtins' for tool list."
)
}),
HelpTopic::Tool(name) => format_tool_help(name, tool_schemas),
}
}
pub fn tool_help(name: &str, schemas: &[ToolSchema]) -> Option<String> {
let schema = schemas.iter().find(|s| s.name == name)?;
let mut output = String::new();
output.push_str(&format!("{} — {}\n", schema.name, schema.description));
output.push_str(&command_aliases_line(&schema.aliases));
output.push('\n');
if schema.params.is_empty() {
output.push_str("No parameters.\n");
} else {
output.push_str("Parameters:\n");
push_params(&mut output, &schema.params, " ");
}
if !schema.subcommands.is_empty() {
output.push_str("\nSubcommands:\n");
output.push_str(&subcommand_roster(&schema.subcommands));
}
if !schema.examples.is_empty() {
output.push_str("\nExamples:\n");
output.push_str(&examples_section(&schema.examples));
}
if !schema.operations.is_empty() {
output.push('\n');
output.push_str(&operations_line(&schema.operations));
}
Some(output)
}
fn push_params(output: &mut String, params: &[ParamSchema], indent: &str) {
for param in params {
let req = if param.required { " (required)" } else { "" };
let aliases = if param.aliases.is_empty() {
String::new()
} else {
format!(" (also: {})", param.aliases.join(", "))
};
output.push_str(&format!(
"{indent}{} : {}{}{}\n{indent} {}\n",
param.name, param.param_type, req, aliases, param.description
));
}
}
pub fn param_lines(params: &[ParamSchema], indent: &str) -> String {
let mut output = String::new();
push_params(&mut output, params, indent);
output
}
pub fn examples_section(examples: &[Example]) -> String {
let mut output = String::new();
for example in examples {
output.push_str(&format!(" # {}\n", example.description));
output.push_str(&format!(" {}\n\n", example.code));
}
output
}
pub fn operations_line(operations: &[String]) -> String {
if operations.is_empty() {
String::new()
} else {
format!("Operations: {}\n", operations.join(", "))
}
}
pub fn command_aliases_line(aliases: &[String]) -> String {
if aliases.is_empty() {
String::new()
} else {
format!("Aliases: {}\n", aliases.join(", "))
}
}
pub fn subcommand_roster(subs: &[ToolSchema]) -> String {
let mut output = String::new();
push_subcommand_roster(&mut output, "", subs);
output
}
fn push_subcommand_roster(output: &mut String, prefix: &str, subs: &[ToolSchema]) {
for sub in subs {
let path = if prefix.is_empty() {
sub.name.clone()
} else {
format!("{prefix} {}", sub.name)
};
if sub.description.is_empty() {
output.push_str(&format!(" {path}\n"));
} else {
output.push_str(&format!(" {path} — {}\n", sub.description));
}
push_params(output, &sub.params, " ");
if !sub.subcommands.is_empty() {
push_subcommand_roster(output, &path, &sub.subcommands);
}
}
}
fn format_tool_help(name: &str, schemas: &[ToolSchema]) -> String {
tool_help(name, schemas).unwrap_or_else(|| {
format!(
"Unknown topic or tool: {}\n\nUse 'help' to see available topics, or 'help builtins' for tool list.",
name
)
})
}
fn format_tool_list(schemas: &[ToolSchema]) -> String {
let mut output = String::from("# Available Builtins\n\n");
let max_len = schemas.iter().map(|s| s.name.len()).max().unwrap_or(0);
for schema in schemas {
output.push_str(&format!(
" {:width$} {}\n",
schema.name,
schema.description,
width = max_len
));
}
output.push_str("\n---\n");
output.push_str("Use 'help <tool>' for detailed help on a specific tool.\n");
output.push_str("Use 'help syntax' for language syntax reference.\n");
output
}
pub fn list_topics() -> Vec<(&'static str, &'static str)> {
vec![
("overview", "What kaish is, list of topics"),
("syntax", "Variables, quoting, pipes, control flow"),
("builtins", "List of available builtins"),
("vfs", "Virtual filesystem mounts and paths"),
("scatter", "Parallel processing (散/集)"),
("ignore", "Ignore file configuration"),
("output-limit", "Output size limit configuration"),
("limits", "Known limitations"),
("overlay", "Copy-on-write overlay mode and kaish-vfs"),
("collections", "Lists & records: literals, access, iteration, lvalues"),
]
}
#[cfg(test)]
mod tests {
use super::*;
use kaish_types::ParamSchema;
fn nested_tool_schema() -> ToolSchema {
let leaf = ToolSchema::new("list", "List the repository's working trees").param(
ParamSchema::optional(
"porcelain",
"bool",
kaish_types::Value::Bool(false),
"Machine-readable output",
),
);
let node = ToolSchema::new("worktree", "Work with the repository's working trees").subcommand(leaf);
ToolSchema::new("git", "Git plumbing and porcelain").subcommand(node)
}
#[test]
fn test_tool_help_recurses_into_nested_subcommands() {
let schema = nested_tool_schema();
let content = tool_help("git", std::slice::from_ref(&schema)).expect("git is registered");
assert!(
content.contains("worktree list — List the repository's working trees"),
"expected full-path leaf line, got:\n{content}"
);
assert!(
content.contains("porcelain"),
"expected leaf parameter to render, got:\n{content}"
);
assert!(
content.contains("Machine-readable output"),
"expected leaf parameter description to render, got:\n{content}"
);
let roster_start = content.find("Subcommands:\n").expect("Subcommands section") + "Subcommands:\n".len();
for line in content[roster_start..].lines() {
if line.is_empty() || line.starts_with(" ") || line.starts_with("Examples:") {
continue; }
assert!(
line.starts_with(" ") && !line.starts_with(" "),
"roster line must start with exactly two spaces: {line:?}"
);
assert!(
line.contains(" — "),
"roster line must use the ' — ' separator: {line:?}"
);
}
}
#[test]
fn test_tool_help_recurses_three_levels() {
let leaf = ToolSchema::new("list", "List sessions in this context").param(
ParamSchema::optional(
"active",
"bool",
kaish_types::Value::Bool(false),
"Only running sessions",
),
);
let session = ToolSchema::new("session", "Session operations").subcommand(leaf);
let context = ToolSchema::new("context", "Context operations").subcommand(session);
let schema = ToolSchema::new("kj", "kaijutsu control").subcommand(context);
let content = tool_help("kj", std::slice::from_ref(&schema)).expect("kj is registered");
assert!(
content.contains("context session list — List sessions in this context"),
"expected three-level full-path leaf line, got:\n{content}"
);
assert!(content.contains("active"), "expected leaf parameter to render, got:\n{content}");
let roster_start = content.find("Subcommands:\n").expect("Subcommands section") + "Subcommands:\n".len();
for line in content[roster_start..].lines() {
if line.contains(" — ") {
assert!(
line.starts_with(" ") && !line.starts_with(" "),
"roster line must stay at exactly two spaces regardless of depth: {line:?}"
);
}
}
}
#[test]
fn test_tool_help_flat_tool_unchanged() {
let schema = ToolSchema::new("cat", "Read and output file contents")
.param(ParamSchema::required("path", "string", "File path to read"));
let content = tool_help("cat", std::slice::from_ref(&schema)).expect("cat is registered");
assert_eq!(
content,
"cat — Read and output file contents\n\nParameters:\n path : string (required)\n File path to read\n"
);
}
#[test]
fn test_tool_help_renders_operations() {
let mut schema = ToolSchema::new("rm", "Remove files");
schema.operations = vec!["fs.remove".to_string()];
let content = tool_help("rm", std::slice::from_ref(&schema)).expect("rm is registered");
assert!(
content.contains("Operations: fs.remove"),
"expected declared effects to render, got:\n{content}"
);
}
#[test]
fn test_tool_help_renders_command_aliases() {
let schema = ToolSchema::new("list", "List sessions").with_command_aliases(["ls"]);
let content = tool_help("list", std::slice::from_ref(&schema)).expect("list is registered");
assert!(
content.contains("Aliases: ls"),
"expected command alias to render, got:\n{content}"
);
}
#[test]
fn test_topic_parsing() {
assert_eq!(HelpTopic::parse_topic(""), HelpTopic::Overview);
assert_eq!(HelpTopic::parse_topic("overview"), HelpTopic::Overview);
assert_eq!(HelpTopic::parse_topic("syntax"), HelpTopic::Syntax);
assert_eq!(HelpTopic::parse_topic("SYNTAX"), HelpTopic::Syntax);
assert_eq!(HelpTopic::parse_topic("builtins"), HelpTopic::Builtins);
assert_eq!(HelpTopic::parse_topic("vfs"), HelpTopic::Vfs);
assert_eq!(HelpTopic::parse_topic("scatter"), HelpTopic::Scatter);
assert_eq!(HelpTopic::parse_topic("集"), HelpTopic::Scatter);
assert_eq!(HelpTopic::parse_topic("output-limit"), HelpTopic::OutputLimit);
assert_eq!(HelpTopic::parse_topic("spill"), HelpTopic::OutputLimit);
assert_eq!(HelpTopic::parse_topic("kaish-output-limit"), HelpTopic::OutputLimit);
assert_eq!(HelpTopic::parse_topic("limits"), HelpTopic::Limits);
assert_eq!(
HelpTopic::parse_topic("grep"),
HelpTopic::Tool("grep".to_string())
);
assert_eq!(
HelpTopic::parse_topic("collections"),
HelpTopic::SyntaxSection("collections".to_string())
);
}
#[test]
fn test_get_help_collections_section() {
let content = get_help(&HelpTopic::SyntaxSection("collections".to_string()), &[]);
assert!(content.contains("Collections (lists & records)"));
assert!(content.contains("xs=[apple banana cherry]"));
assert!(SYNTAX.contains("xs=[apple banana cherry]"));
}
#[test]
fn test_get_help_unknown_syntax_section_falls_back() {
let content = get_help(&HelpTopic::SyntaxSection("not-a-real-section".to_string()), &[]);
assert!(content.contains("Unknown topic or tool"));
}
#[test]
fn test_static_content_embedded() {
assert!(OVERVIEW.contains("kaish"));
assert!(SYNTAX.contains("Variables"));
assert!(VFS.contains("Mount Points"));
assert!(SCATTER.contains("scatter"));
assert!(IGNORE.contains("kaish-ignore"));
assert!(OUTPUT_LIMIT.contains("kaish-output-limit"));
assert!(LIMITS.contains("Limitations"));
}
#[test]
fn test_get_help_overview() {
let content = get_help(&HelpTopic::Overview, &[]);
assert!(content.contains("kaish"));
assert!(content.contains("help syntax"));
}
#[test]
fn test_get_help_unknown_tool() {
let content = get_help(&HelpTopic::Tool("nonexistent".to_string()), &[]);
assert!(content.contains("Unknown topic or tool"));
}
#[test]
fn test_tool_help_none_for_missing() {
assert!(tool_help("nonexistent", &[]).is_none());
}
}