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::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\n", schema.name, schema.description));
114
115    if schema.params.is_empty() {
116        output.push_str("No parameters.\n");
117    } else {
118        output.push_str("Parameters:\n");
119        push_params(&mut output, &schema.params, "  ");
120    }
121
122    // A subcommand-aware tool (`kj`, every wrapped command) keeps its real
123    // grammar here, one level down. Without this the whole allowlist a
124    // wrapped command publishes — its verbs, their flags, and the
125    // constraints in their descriptions — was invisible to `help`.
126    if !schema.subcommands.is_empty() {
127        output.push_str("\nSubcommands:\n");
128        for sub in &schema.subcommands {
129            if sub.description.is_empty() {
130                output.push_str(&format!("  {}\n", sub.name));
131            } else {
132                output.push_str(&format!("  {} — {}\n", sub.name, sub.description));
133            }
134            push_params(&mut output, &sub.params, "    ");
135        }
136    }
137
138    if !schema.examples.is_empty() {
139        output.push_str("\nExamples:\n");
140        for example in &schema.examples {
141            output.push_str(&format!("  # {}\n", example.description));
142            output.push_str(&format!("  {}\n\n", example.code));
143        }
144    }
145
146    Some(output)
147}
148
149/// One line per parameter, plus its description indented under it.
150///
151/// Aliases are named here because they are the spelling agents actually
152/// write: a declaration that publishes `-n` for `--max-count` was telling
153/// `help` something it then dropped.
154fn push_params(output: &mut String, params: &[kaish_types::ParamSchema], indent: &str) {
155    for param in params {
156        let req = if param.required { " (required)" } else { "" };
157        let aliases = if param.aliases.is_empty() {
158            String::new()
159        } else {
160            format!(" (also: {})", param.aliases.join(", "))
161        };
162        output.push_str(&format!(
163            "{indent}{} : {}{}{}\n{indent}  {}\n",
164            param.name, param.param_type, req, aliases, param.description
165        ));
166    }
167}
168
169/// Format help for a single tool.
170fn format_tool_help(name: &str, schemas: &[ToolSchema]) -> String {
171    tool_help(name, schemas).unwrap_or_else(|| {
172        format!(
173            "Unknown topic or tool: {}\n\nUse 'help' to see available topics, or 'help builtins' for tool list.",
174            name
175        )
176    })
177}
178
179/// Format a flat alphabetical list of all available tools.
180///
181/// Schemas arrive sorted from the registry; only registered tools appear,
182/// so feature-gated or unloaded builtins are omitted naturally.
183fn format_tool_list(schemas: &[ToolSchema]) -> String {
184    let mut output = String::from("# Available Builtins\n\n");
185
186    let max_len = schemas.iter().map(|s| s.name.len()).max().unwrap_or(0);
187
188    for schema in schemas {
189        output.push_str(&format!(
190            "  {:width$}  {}\n",
191            schema.name,
192            schema.description,
193            width = max_len
194        ));
195    }
196
197    output.push_str("\n---\n");
198    output.push_str("Use 'help <tool>' for detailed help on a specific tool.\n");
199    output.push_str("Use 'help syntax' for language syntax reference.\n");
200
201    output
202}
203
204/// List available help topics (for autocomplete, etc.).
205pub fn list_topics() -> Vec<(&'static str, &'static str)> {
206    vec![
207        ("overview", "What kaish is, list of topics"),
208        ("syntax", "Variables, quoting, pipes, control flow"),
209        ("builtins", "List of available builtins"),
210        ("vfs", "Virtual filesystem mounts and paths"),
211        ("scatter", "Parallel processing (散/集)"),
212        ("ignore", "Ignore file configuration"),
213        ("output-limit", "Output size limit configuration"),
214        ("limits", "Known limitations"),
215        ("overlay", "Copy-on-write overlay mode and kaish-vfs"),
216        ("collections", "Lists & records: literals, access, iteration, lvalues"),
217    ]
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn test_topic_parsing() {
226        assert_eq!(HelpTopic::parse_topic(""), HelpTopic::Overview);
227        assert_eq!(HelpTopic::parse_topic("overview"), HelpTopic::Overview);
228        assert_eq!(HelpTopic::parse_topic("syntax"), HelpTopic::Syntax);
229        assert_eq!(HelpTopic::parse_topic("SYNTAX"), HelpTopic::Syntax);
230        assert_eq!(HelpTopic::parse_topic("builtins"), HelpTopic::Builtins);
231        assert_eq!(HelpTopic::parse_topic("vfs"), HelpTopic::Vfs);
232        assert_eq!(HelpTopic::parse_topic("scatter"), HelpTopic::Scatter);
233        assert_eq!(HelpTopic::parse_topic("集"), HelpTopic::Scatter);
234        assert_eq!(HelpTopic::parse_topic("output-limit"), HelpTopic::OutputLimit);
235        assert_eq!(HelpTopic::parse_topic("spill"), HelpTopic::OutputLimit);
236        assert_eq!(HelpTopic::parse_topic("kaish-output-limit"), HelpTopic::OutputLimit);
237        assert_eq!(HelpTopic::parse_topic("limits"), HelpTopic::Limits);
238        assert_eq!(
239            HelpTopic::parse_topic("grep"),
240            HelpTopic::Tool("grep".to_string())
241        );
242        assert_eq!(
243            HelpTopic::parse_topic("collections"),
244            HelpTopic::SyntaxSection("collections".to_string())
245        );
246    }
247
248    #[test]
249    fn test_get_help_collections_section() {
250        let content = get_help(&HelpTopic::SyntaxSection("collections".to_string()), &[]);
251        assert!(content.contains("Collections (lists & records)"));
252        assert!(content.contains("xs=[apple banana cherry]"));
253        // Single-sourced with `help syntax` — not a second, hand-written copy.
254        assert!(SYNTAX.contains("xs=[apple banana cherry]"));
255    }
256
257    #[test]
258    fn test_get_help_unknown_syntax_section_falls_back() {
259        // Guards against constructing the variant directly with a bad key
260        // (bypassing parse_topic's existence check) and panicking.
261        let content = get_help(&HelpTopic::SyntaxSection("not-a-real-section".to_string()), &[]);
262        assert!(content.contains("Unknown topic or tool"));
263    }
264
265    #[test]
266    fn test_static_content_embedded() {
267        // Verify the markdown files are embedded
268        assert!(OVERVIEW.contains("kaish"));
269        assert!(SYNTAX.contains("Variables"));
270        assert!(VFS.contains("Mount Points"));
271        assert!(SCATTER.contains("scatter"));
272        assert!(IGNORE.contains("kaish-ignore"));
273        assert!(OUTPUT_LIMIT.contains("kaish-output-limit"));
274        assert!(LIMITS.contains("Limitations"));
275    }
276
277    #[test]
278    fn test_get_help_overview() {
279        let content = get_help(&HelpTopic::Overview, &[]);
280        assert!(content.contains("kaish"));
281        assert!(content.contains("help syntax"));
282    }
283
284    #[test]
285    fn test_get_help_unknown_tool() {
286        let content = get_help(&HelpTopic::Tool("nonexistent".to_string()), &[]);
287        assert!(content.contains("Unknown topic or tool"));
288    }
289
290    #[test]
291    fn test_tool_help_none_for_missing() {
292        assert!(tool_help("nonexistent", &[]).is_none());
293    }
294}