1use kaish_types::{Example, ParamSchema, ToolSchema};
9
10use crate::compose::render_syntax_section;
11use crate::content::{IGNORE, LIMITS, OUTPUT_LIMIT, OVERLAY, OVERVIEW, SCATTER, SYNTAX, VFS};
12
13#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum HelpTopic {
16 Overview,
18 Syntax,
20 Builtins,
22 Vfs,
24 Scatter,
26 Ignore,
28 OutputLimit,
30 Limits,
32 Overlay,
34 SyntaxSection(String),
37 Tool(String),
39}
40
41impl HelpTopic {
42 pub fn parse_topic(s: &str) -> Self {
47 match s.to_lowercase().as_str() {
48 "" | "overview" | "help" => Self::Overview,
49 "syntax" | "language" | "lang" => Self::Syntax,
50 "builtins" | "tools" | "commands" => Self::Builtins,
51 "vfs" | "filesystem" | "fs" | "paths" => Self::Vfs,
52 "scatter" | "gather" | "parallel" | "散" | "集" => Self::Scatter,
53 "ignore" | "gitignore" | "kaish-ignore" => Self::Ignore,
54 "output-limit" | "spill" | "truncate" | "kaish-output-limit" => Self::OutputLimit,
55 "limits" | "limitations" | "missing" => Self::Limits,
56 "overlay" | "kaish-vfs" | "vfs-overlay" => Self::Overlay,
57 other if render_syntax_section(other).is_some() => Self::SyntaxSection(other.to_string()),
58 other => Self::Tool(other.to_string()),
59 }
60 }
61
62 pub fn description(&self) -> &'static str {
64 match self {
65 Self::Overview => "What kaish is, list of topics",
66 Self::Syntax => "Variables, quoting, pipes, control flow",
67 Self::Builtins => "List of available builtins",
68 Self::Vfs => "Virtual filesystem mounts and paths",
69 Self::Scatter => "Parallel processing (散/集)",
70 Self::Ignore => "Ignore file configuration",
71 Self::OutputLimit => "Output size limit configuration",
72 Self::Limits => "Known limitations",
73 Self::Overlay => "Copy-on-write overlay mode and kaish-vfs",
74 Self::SyntaxSection(_) => "A single syntax reference section",
75 Self::Tool(_) => "Help for a specific tool",
76 }
77 }
78}
79
80pub fn get_help(topic: &HelpTopic, tool_schemas: &[ToolSchema]) -> String {
86 match topic {
87 HelpTopic::Overview => OVERVIEW.to_string(),
88 HelpTopic::Syntax => SYNTAX.to_string(),
89 HelpTopic::Builtins => format_tool_list(tool_schemas),
90 HelpTopic::Vfs => VFS.to_string(),
91 HelpTopic::Scatter => SCATTER.to_string(),
92 HelpTopic::Ignore => IGNORE.to_string(),
93 HelpTopic::OutputLimit => OUTPUT_LIMIT.to_string(),
94 HelpTopic::Limits => LIMITS.to_string(),
95 HelpTopic::Overlay => OVERLAY.to_string(),
96 HelpTopic::SyntaxSection(key) => render_syntax_section(key).unwrap_or_else(|| {
97 format!(
98 "Unknown topic or tool: {key}\n\nUse 'help' to see available topics, or 'help builtins' for tool list."
99 )
100 }),
101 HelpTopic::Tool(name) => format_tool_help(name, tool_schemas),
102 }
103}
104
105pub fn tool_help(name: &str, schemas: &[ToolSchema]) -> Option<String> {
110 let schema = schemas.iter().find(|s| s.name == name)?;
111 let mut output = String::new();
112
113 output.push_str(&format!("{} — {}\n", schema.name, schema.description));
114 output.push_str(&command_aliases_line(&schema.aliases));
115 output.push('\n');
116
117 if schema.params.is_empty() {
118 output.push_str("No parameters.\n");
119 } else {
120 output.push_str("Parameters:\n");
121 push_params(&mut output, &schema.params, " ");
122 }
123
124 if !schema.subcommands.is_empty() {
129 output.push_str("\nSubcommands:\n");
130 output.push_str(&subcommand_roster(&schema.subcommands));
131 }
132
133 if !schema.examples.is_empty() {
134 output.push_str("\nExamples:\n");
135 output.push_str(&examples_section(&schema.examples));
136 }
137
138 if !schema.operations.is_empty() {
139 output.push('\n');
140 output.push_str(&operations_line(&schema.operations));
141 }
142
143 Some(output)
144}
145
146fn push_params(output: &mut String, params: &[ParamSchema], indent: &str) {
152 for param in params {
153 let req = if param.required { " (required)" } else { "" };
154 let aliases = if param.aliases.is_empty() {
155 String::new()
156 } else {
157 format!(" (also: {})", param.aliases.join(", "))
158 };
159 output.push_str(&format!(
160 "{indent}{} : {}{}{}\n{indent} {}\n",
161 param.name, param.param_type, req, aliases, param.description
162 ));
163 }
164}
165
166pub fn param_lines(params: &[ParamSchema], indent: &str) -> String {
172 let mut output = String::new();
173 push_params(&mut output, params, indent);
174 output
175}
176
177pub fn examples_section(examples: &[Example]) -> String {
181 let mut output = String::new();
182 for example in examples {
183 output.push_str(&format!(" # {}\n", example.description));
184 output.push_str(&format!(" {}\n\n", example.code));
185 }
186 output
187}
188
189pub fn operations_line(operations: &[String]) -> String {
192 if operations.is_empty() {
193 String::new()
194 } else {
195 format!("Operations: {}\n", operations.join(", "))
196 }
197}
198
199pub fn command_aliases_line(aliases: &[String]) -> String {
203 if aliases.is_empty() {
204 String::new()
205 } else {
206 format!("Aliases: {}\n", aliases.join(", "))
207 }
208}
209
210pub fn subcommand_roster(subs: &[ToolSchema]) -> String {
224 let mut output = String::new();
225 push_subcommand_roster(&mut output, "", subs);
226 output
227}
228
229fn push_subcommand_roster(output: &mut String, prefix: &str, subs: &[ToolSchema]) {
232 for sub in subs {
233 let path = if prefix.is_empty() {
234 sub.name.clone()
235 } else {
236 format!("{prefix} {}", sub.name)
237 };
238 if sub.description.is_empty() {
239 output.push_str(&format!(" {path}\n"));
240 } else {
241 output.push_str(&format!(" {path} — {}\n", sub.description));
242 }
243 push_params(output, &sub.params, " ");
244 if !sub.subcommands.is_empty() {
245 push_subcommand_roster(output, &path, &sub.subcommands);
246 }
247 }
248}
249
250fn format_tool_help(name: &str, schemas: &[ToolSchema]) -> String {
252 tool_help(name, schemas).unwrap_or_else(|| {
253 format!(
254 "Unknown topic or tool: {}\n\nUse 'help' to see available topics, or 'help builtins' for tool list.",
255 name
256 )
257 })
258}
259
260fn format_tool_list(schemas: &[ToolSchema]) -> String {
265 let mut output = String::from("# Available Builtins\n\n");
266
267 let max_len = schemas.iter().map(|s| s.name.len()).max().unwrap_or(0);
268
269 for schema in schemas {
270 output.push_str(&format!(
271 " {:width$} {}\n",
272 schema.name,
273 schema.description,
274 width = max_len
275 ));
276 }
277
278 output.push_str("\n---\n");
279 output.push_str("Use 'help <tool>' for detailed help on a specific tool.\n");
280 output.push_str("Use 'help syntax' for language syntax reference.\n");
281
282 output
283}
284
285pub fn list_topics() -> Vec<(&'static str, &'static str)> {
287 vec![
288 ("overview", "What kaish is, list of topics"),
289 ("syntax", "Variables, quoting, pipes, control flow"),
290 ("builtins", "List of available builtins"),
291 ("vfs", "Virtual filesystem mounts and paths"),
292 ("scatter", "Parallel processing (散/集)"),
293 ("ignore", "Ignore file configuration"),
294 ("output-limit", "Output size limit configuration"),
295 ("limits", "Known limitations"),
296 ("overlay", "Copy-on-write overlay mode and kaish-vfs"),
297 ("collections", "Lists & records: literals, access, iteration, lvalues"),
298 ]
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use kaish_types::ParamSchema;
305
306 fn nested_tool_schema() -> ToolSchema {
309 let leaf = ToolSchema::new("list", "List the repository's working trees").param(
310 ParamSchema::optional(
311 "porcelain",
312 "bool",
313 kaish_types::Value::Bool(false),
314 "Machine-readable output",
315 ),
316 );
317 let node = ToolSchema::new("worktree", "Work with the repository's working trees").subcommand(leaf);
318 ToolSchema::new("git", "Git plumbing and porcelain").subcommand(node)
319 }
320
321 #[test]
322 fn test_tool_help_recurses_into_nested_subcommands() {
323 let schema = nested_tool_schema();
324 let content = tool_help("git", std::slice::from_ref(&schema)).expect("git is registered");
325
326 assert!(
328 content.contains("worktree list — List the repository's working trees"),
329 "expected full-path leaf line, got:\n{content}"
330 );
331 assert!(
333 content.contains("porcelain"),
334 "expected leaf parameter to render, got:\n{content}"
335 );
336 assert!(
337 content.contains("Machine-readable output"),
338 "expected leaf parameter description to render, got:\n{content}"
339 );
340
341 let roster_start = content.find("Subcommands:\n").expect("Subcommands section") + "Subcommands:\n".len();
345 for line in content[roster_start..].lines() {
346 if line.is_empty() || line.starts_with(" ") || line.starts_with("Examples:") {
347 continue; }
349 assert!(
350 line.starts_with(" ") && !line.starts_with(" "),
351 "roster line must start with exactly two spaces: {line:?}"
352 );
353 assert!(
354 line.contains(" — "),
355 "roster line must use the ' — ' separator: {line:?}"
356 );
357 }
358 }
359
360 #[test]
361 fn test_tool_help_recurses_three_levels() {
362 let leaf = ToolSchema::new("list", "List sessions in this context").param(
365 ParamSchema::optional(
366 "active",
367 "bool",
368 kaish_types::Value::Bool(false),
369 "Only running sessions",
370 ),
371 );
372 let session = ToolSchema::new("session", "Session operations").subcommand(leaf);
373 let context = ToolSchema::new("context", "Context operations").subcommand(session);
374 let schema = ToolSchema::new("kj", "kaijutsu control").subcommand(context);
375
376 let content = tool_help("kj", std::slice::from_ref(&schema)).expect("kj is registered");
377 assert!(
378 content.contains("context session list — List sessions in this context"),
379 "expected three-level full-path leaf line, got:\n{content}"
380 );
381 assert!(content.contains("active"), "expected leaf parameter to render, got:\n{content}");
382
383 let roster_start = content.find("Subcommands:\n").expect("Subcommands section") + "Subcommands:\n".len();
384 for line in content[roster_start..].lines() {
385 if line.contains(" — ") {
386 assert!(
387 line.starts_with(" ") && !line.starts_with(" "),
388 "roster line must stay at exactly two spaces regardless of depth: {line:?}"
389 );
390 }
391 }
392 }
393
394 #[test]
395 fn test_tool_help_flat_tool_unchanged() {
396 let schema = ToolSchema::new("cat", "Read and output file contents")
399 .param(ParamSchema::required("path", "string", "File path to read"));
400 let content = tool_help("cat", std::slice::from_ref(&schema)).expect("cat is registered");
401 assert_eq!(
402 content,
403 "cat — Read and output file contents\n\nParameters:\n path : string (required)\n File path to read\n"
404 );
405 }
406
407 #[test]
408 fn test_tool_help_renders_operations() {
409 let mut schema = ToolSchema::new("rm", "Remove files");
412 schema.operations = vec!["fs.remove".to_string()];
413 let content = tool_help("rm", std::slice::from_ref(&schema)).expect("rm is registered");
414 assert!(
415 content.contains("Operations: fs.remove"),
416 "expected declared effects to render, got:\n{content}"
417 );
418 }
419
420 #[test]
421 fn test_tool_help_renders_command_aliases() {
422 let schema = ToolSchema::new("list", "List sessions").with_command_aliases(["ls"]);
426 let content = tool_help("list", std::slice::from_ref(&schema)).expect("list is registered");
427 assert!(
428 content.contains("Aliases: ls"),
429 "expected command alias to render, got:\n{content}"
430 );
431 }
432
433 #[test]
434 fn test_topic_parsing() {
435 assert_eq!(HelpTopic::parse_topic(""), HelpTopic::Overview);
436 assert_eq!(HelpTopic::parse_topic("overview"), HelpTopic::Overview);
437 assert_eq!(HelpTopic::parse_topic("syntax"), HelpTopic::Syntax);
438 assert_eq!(HelpTopic::parse_topic("SYNTAX"), HelpTopic::Syntax);
439 assert_eq!(HelpTopic::parse_topic("builtins"), HelpTopic::Builtins);
440 assert_eq!(HelpTopic::parse_topic("vfs"), HelpTopic::Vfs);
441 assert_eq!(HelpTopic::parse_topic("scatter"), HelpTopic::Scatter);
442 assert_eq!(HelpTopic::parse_topic("集"), HelpTopic::Scatter);
443 assert_eq!(HelpTopic::parse_topic("output-limit"), HelpTopic::OutputLimit);
444 assert_eq!(HelpTopic::parse_topic("spill"), HelpTopic::OutputLimit);
445 assert_eq!(HelpTopic::parse_topic("kaish-output-limit"), HelpTopic::OutputLimit);
446 assert_eq!(HelpTopic::parse_topic("limits"), HelpTopic::Limits);
447 assert_eq!(
448 HelpTopic::parse_topic("grep"),
449 HelpTopic::Tool("grep".to_string())
450 );
451 assert_eq!(
452 HelpTopic::parse_topic("collections"),
453 HelpTopic::SyntaxSection("collections".to_string())
454 );
455 }
456
457 #[test]
458 fn test_get_help_collections_section() {
459 let content = get_help(&HelpTopic::SyntaxSection("collections".to_string()), &[]);
460 assert!(content.contains("Collections (lists & records)"));
461 assert!(content.contains("xs=[apple banana cherry]"));
462 assert!(SYNTAX.contains("xs=[apple banana cherry]"));
464 }
465
466 #[test]
467 fn test_get_help_unknown_syntax_section_falls_back() {
468 let content = get_help(&HelpTopic::SyntaxSection("not-a-real-section".to_string()), &[]);
471 assert!(content.contains("Unknown topic or tool"));
472 }
473
474 #[test]
475 fn test_static_content_embedded() {
476 assert!(OVERVIEW.contains("kaish"));
478 assert!(SYNTAX.contains("Variables"));
479 assert!(VFS.contains("Mount Points"));
480 assert!(SCATTER.contains("scatter"));
481 assert!(IGNORE.contains("kaish-ignore"));
482 assert!(OUTPUT_LIMIT.contains("kaish-output-limit"));
483 assert!(LIMITS.contains("Limitations"));
484 }
485
486 #[test]
487 fn test_get_help_overview() {
488 let content = get_help(&HelpTopic::Overview, &[]);
489 assert!(content.contains("kaish"));
490 assert!(content.contains("help syntax"));
491 }
492
493 #[test]
494 fn test_get_help_unknown_tool() {
495 let content = get_help(&HelpTopic::Tool("nonexistent".to_string()), &[]);
496 assert!(content.contains("Unknown topic or tool"));
497 }
498
499 #[test]
500 fn test_tool_help_none_for_missing() {
501 assert!(tool_help("nonexistent", &[]).is_none());
502 }
503}