Skip to main content

kaish_help/
topic.rs

1//! Topic compatibility surface for kaish help.
2//!
3//! Backs the `help <topic>` builtin and embedder prompt surfaces: topic-based
4//! whole-document help embedded at compile time, plus dynamic tool help from the
5//! tool registry.
6//! Behavior here is intentionally byte-stable — frontends and tests depend on it.
7
8use 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/// Help topics available in kaish.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum HelpTopic {
16    /// Overview of kaish with topic list.
17    Overview,
18    /// Syntax reference: variables, quoting, pipes, control flow.
19    Syntax,
20    /// List of all available builtins.
21    Builtins,
22    /// Virtual filesystem mounts and paths.
23    Vfs,
24    /// Scatter/gather parallel processing.
25    Scatter,
26    /// Ignore file configuration.
27    Ignore,
28    /// Output size limit configuration.
29    OutputLimit,
30    /// Known limitations.
31    Limits,
32    /// Overlay VFS mode and kaish-vfs builtin.
33    Overlay,
34    /// A single subsystem-sized syntax section (e.g. `collections`), composed
35    /// straight from its `Syntax` fragment — single-sourced with `help syntax`.
36    SyntaxSection(String),
37    /// Help for a specific tool.
38    Tool(String),
39}
40
41impl HelpTopic {
42    /// Parse a topic string into a HelpTopic.
43    ///
44    /// Returns Overview for empty/None, specific topics for known names,
45    /// or Tool(name) for anything else (assumes it's a tool name).
46    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    /// Get a short description of this topic.
63    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
80/// Get help content for a topic.
81///
82/// For static topics, returns embedded markdown.
83/// For `Builtins`, generates a tool list from the provided schemas.
84/// For `Tool(name)`, looks up the tool in the schemas.
85pub 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
105/// Format help for a single tool, or `None` if no such tool is registered.
106///
107/// The composition surface uses this; the `Unknown topic…` fallback lives in
108/// `format_tool_help` for the `help <topic>` command path.
109pub 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    // A subcommand-aware tool (`kj`, every wrapped command) keeps its real
125    // grammar here, at any depth. Without this the whole allowlist a
126    // wrapped command publishes — its verbs, their flags, and the
127    // constraints in their descriptions — was invisible to `help`.
128    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
146/// One line per parameter, plus its description indented under it.
147///
148/// Aliases are named here because they are the spelling agents actually
149/// write: a declaration that publishes `-n` for `--max-count` was telling
150/// `help` something it then dropped.
151fn 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
166/// `push_params` as an owned string, for a caller across the crate
167/// boundary that cannot hold the buffer `push_params` writes into
168/// (`kaish-tools <name>` in `kaish-kernel`). `indent` is real per-caller
169/// state — `"  "` for a tool's own parameters, `"    "` for a subcommand's
170/// — unlike the accumulator, so it stays a parameter here.
171pub 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
177/// The example lines for a tool: a `#`-comment naming what it demonstrates,
178/// then the command, blank-line separated. The caller writes its own
179/// `Examples:` header — the two surfaces place it differently.
180pub 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
189/// A tool's declared effect ids (`fs.remove`, `fs.overwrite`, …) as one
190/// line, or empty when it declares none. See `ToolSchema.operations`.
191pub 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
199/// A one-line note naming a tool's command-level aliases (`ls` for
200/// `list`), distinct from a parameter's own aliases (see `push_params`).
201/// Empty when the tool declares none.
202pub 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
210/// The roster lines naming every subcommand at any depth, plus each one's
211/// parameters. The caller writes its own `Subcommands:` header.
212///
213/// `ToolSchema::subcommands` is recursive — a node (`worktree`) can hold a
214/// leaf (`list`) that holds another node — but the roster stays flat: every
215/// line renders the full path (`worktree list`) at the same two-space
216/// indent, never a deeper indent per level. kaish-extras parses this roster
217/// by column: exactly two spaces, then the ` — ` (space, em-dash, space)
218/// separator. A nested indent or a different separator breaks that reader.
219///
220/// Public so `help <tool>` and `kaish-tools <name>` render one roster from
221/// one implementation instead of drifting into two spellings of a tool's
222/// grammar.
223pub fn subcommand_roster(subs: &[ToolSchema]) -> String {
224    let mut output = String::new();
225    push_subcommand_roster(&mut output, "", subs);
226    output
227}
228
229/// The recursion behind [`subcommand_roster`]. `prefix` is the path accumulated
230/// so far and is a detail of the walk, which is why callers never supply it.
231fn 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
250/// Format help for a single tool.
251fn 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
260/// Format a flat alphabetical list of all available tools.
261///
262/// Schemas arrive sorted from the registry; only registered tools appear,
263/// so feature-gated or unloaded builtins are omitted naturally.
264fn 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
285/// List available help topics (for autocomplete, etc.).
286pub 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    /// A two-level grammar (`git worktree list --porcelain`) — the node
307    /// (`worktree`) has no params of its own, the leaf (`list`) does.
308    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        // The leaf's full path names the actual verb, not just the node.
327        assert!(
328            content.contains("worktree list — List the repository's working trees"),
329            "expected full-path leaf line, got:\n{content}"
330        );
331        // The leaf's parameter renders too.
332        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        // Flat-roster contract: every roster line is exactly two spaces of
342        // indent, path and description joined by " — " (space, em-dash,
343        // space) — kaish-extras parses this shape.
344        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; // param line, or past the roster
348            }
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        // A wrapped command can declare grammar deeper than two levels
363        // (`kj context session list --active`) — depth must not cap at 2.
364        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        // Control: a tool with no subcommands, aliases, examples, or
397        // operations renders byte-identical to before this change.
398        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        // `kaish-tools <name>` already named a tool's declared effects;
410        // `help <tool>` silently dropped them.
411        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        // Command-level aliases (`ls` for `list`) are real, shipped data —
423        // schema_from_clap reflects them from clap — but neither `help
424        // <tool>` nor `kaish-tools <name>` named them.
425        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        // Single-sourced with `help syntax` — not a second, hand-written copy.
463        assert!(SYNTAX.contains("xs=[apple banana cherry]"));
464    }
465
466    #[test]
467    fn test_get_help_unknown_syntax_section_falls_back() {
468        // Guards against constructing the variant directly with a bad key
469        // (bypassing parse_topic's existence check) and panicking.
470        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        // Verify the markdown files are embedded
477        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}