Skip to main content

mermaid_cli/domain/
slash_commands.rs

1//! Single source of truth for slash commands. Used by:
2//! - The palette widget (rendering + filtering)
3//! - The dispatcher in `command_handler.rs` (validating known commands)
4//! - The `/help` handler (rendering the command list)
5//!
6//! Adding a new command means adding one entry to `COMMAND_REGISTRY`
7//! plus wiring its handler into the `match` in `handle_command`.
8//! The palette + help text update automatically.
9
10/// Metadata for one slash command. Names exclude the leading `/`.
11#[derive(Debug, Clone, Copy)]
12pub struct SlashCommand {
13    /// Canonical command name without the leading `/`. Lowercase.
14    pub name: &'static str,
15    /// Alternative names that route to the same handler. Used by the
16    /// dispatcher AND prefix filter — typing `/q` matches `/quit`.
17    pub aliases: &'static [&'static str],
18    /// One-line user-visible description shown in palette and `/help`.
19    pub description: &'static str,
20    /// Optional argument hint shown after the command name in the
21    /// palette, e.g. `Some("[name]")` for `/model [name]`.
22    pub arg_hint: Option<&'static str>,
23}
24
25/// Authoritative registry of all slash commands. Order here is the
26/// order shown in the palette and `/help`.
27pub const COMMAND_REGISTRY: &[SlashCommand] = &[
28    SlashCommand {
29        name: "model",
30        aliases: &[],
31        description: "Switch model (auto-pulls if needed) or show current",
32        arg_hint: Some("[name]"),
33    },
34    SlashCommand {
35        name: "reasoning",
36        aliases: &[],
37        description: "Set reasoning depth (none, minimal, low, medium, high, max, xhigh)",
38        arg_hint: Some("[level]"),
39    },
40    SlashCommand {
41        name: "clear",
42        aliases: &[],
43        description: "Clear chat history",
44        arg_hint: None,
45    },
46    SlashCommand {
47        name: "save",
48        aliases: &[],
49        description: "Save current conversation",
50        arg_hint: Some("[name]"),
51    },
52    SlashCommand {
53        name: "load",
54        aliases: &[],
55        description: "Load a conversation",
56        arg_hint: Some("[name]"),
57    },
58    SlashCommand {
59        name: "list",
60        aliases: &[],
61        description: "List saved conversations",
62        arg_hint: None,
63    },
64    SlashCommand {
65        name: "usage",
66        aliases: &[],
67        description: "Show provider token usage and session totals",
68        arg_hint: None,
69    },
70    SlashCommand {
71        name: "context",
72        aliases: &[],
73        description: "Show current context-window estimate and prompt budget",
74        arg_hint: None,
75    },
76    SlashCommand {
77        name: "compact",
78        aliases: &["compress", "summarize"],
79        description: "Compact conversation context with optional focus instructions",
80        arg_hint: Some("[instructions]"),
81    },
82    SlashCommand {
83        name: "cloud-setup",
84        aliases: &[],
85        description: "Configure Ollama Cloud API key",
86        arg_hint: None,
87    },
88    SlashCommand {
89        name: "help",
90        aliases: &["h"],
91        description: "Show command help",
92        arg_hint: None,
93    },
94    SlashCommand {
95        name: "quit",
96        aliases: &["q"],
97        description: "Quit the application",
98        arg_hint: None,
99    },
100];
101
102/// Filter the registry by a typed prefix (after stripping the leading
103/// `/`). An empty prefix returns the full registry. Matches against the
104/// canonical name AND any aliases — typing `/q` finds `quit` because
105/// `q` is a `quit` alias. Result preserves registry order (stable).
106pub fn filter_by_prefix(typed: &str) -> Vec<&'static SlashCommand> {
107    let needle = typed.to_lowercase();
108    if needle.is_empty() {
109        return COMMAND_REGISTRY.iter().collect();
110    }
111    COMMAND_REGISTRY
112        .iter()
113        .filter(|cmd| {
114            cmd.name.starts_with(&needle) || cmd.aliases.iter().any(|a| a.starts_with(&needle))
115        })
116        .collect()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn filter_by_prefix_empty_returns_all() {
125        let result = filter_by_prefix("");
126        assert_eq!(result.len(), COMMAND_REGISTRY.len());
127        // Order preserved.
128        assert_eq!(result[0].name, COMMAND_REGISTRY[0].name);
129    }
130
131    #[test]
132    fn filter_by_prefix_exact_match() {
133        let result = filter_by_prefix("model");
134        assert_eq!(result.len(), 1);
135        assert_eq!(result[0].name, "model");
136    }
137
138    #[test]
139    fn filter_by_prefix_partial_prefix_matches_one() {
140        let result = filter_by_prefix("mod");
141        assert_eq!(result.len(), 1);
142        assert_eq!(result[0].name, "model");
143    }
144
145    #[test]
146    fn filter_by_prefix_no_match_returns_empty() {
147        let result = filter_by_prefix("zzzzz");
148        assert!(result.is_empty());
149    }
150
151    #[test]
152    fn filter_by_prefix_matches_aliases() {
153        // `/q` should find `quit` via its alias.
154        let result = filter_by_prefix("q");
155        assert!(
156            result.iter().any(|c| c.name == "quit"),
157            "expected quit in: {:?}",
158            result.iter().map(|c| c.name).collect::<Vec<_>>()
159        );
160    }
161
162    #[test]
163    fn filter_by_prefix_is_case_insensitive() {
164        // User shouldn't have to type lowercase /Q or /MODEL.
165        let upper = filter_by_prefix("MODEL");
166        assert_eq!(upper.len(), 1);
167        assert_eq!(upper[0].name, "model");
168    }
169
170    #[test]
171    fn registry_has_no_duplicate_names() {
172        // Defensive: catches accidental duplicate entries during
173        // registry maintenance. Duplicate names would route ambiguously
174        // in the dispatcher.
175        let mut names: Vec<&str> = COMMAND_REGISTRY.iter().map(|c| c.name).collect();
176        names.sort_unstable();
177        let len_before = names.len();
178        names.dedup();
179        assert_eq!(
180            names.len(),
181            len_before,
182            "duplicate command name detected in COMMAND_REGISTRY"
183        );
184    }
185}