Skip to main content

room_cli/plugin/
help.rs

1use super::{BoxFuture, CommandContext, CommandInfo, ParamType, Plugin, PluginResult};
2
3/// Built-in `/help` plugin. Lists all available commands or shows details
4/// for a specific command. Dogfoods the plugin API — uses
5/// `ctx.available_commands` to enumerate the registry without holding a
6/// reference to it.
7pub struct HelpPlugin;
8
9impl Plugin for HelpPlugin {
10    fn name(&self) -> &str {
11        "help"
12    }
13
14    fn commands(&self) -> Vec<CommandInfo> {
15        vec![CommandInfo {
16            name: "help".to_owned(),
17            description: "List available commands or get help for a specific command".to_owned(),
18            usage: "/help [command]".to_owned(),
19            params: vec![super::ParamSchema {
20                name: "command".to_owned(),
21                param_type: ParamType::Text,
22                required: false,
23                description: "Command name to get help for".to_owned(),
24            }],
25        }]
26    }
27
28    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
29        Box::pin(async move {
30            if let Some(target) = ctx.params.first() {
31                // Show help for a specific command
32                let target = target.strip_prefix('/').unwrap_or(target);
33
34                // Check plugin/available commands first
35                if let Some(cmd) = ctx.available_commands.iter().find(|c| c.name == target) {
36                    return Ok(PluginResult::Reply(format_command_help(cmd)));
37                }
38                // Also check built-in commands
39                let builtins = super::builtin_command_infos();
40                if let Some(cmd) = builtins.iter().find(|c| c.name == target) {
41                    return Ok(PluginResult::Reply(format_command_help(cmd)));
42                }
43                return Ok(PluginResult::Reply(format!("unknown command: /{target}")));
44            }
45
46            // List all commands: built-ins first, then plugins
47            let builtins = super::builtin_command_infos();
48            let mut lines = vec!["available commands:".to_owned()];
49            for cmd in &builtins {
50                lines.push(format!("  {} — {}", cmd.usage, cmd.description));
51            }
52            for cmd in &ctx.available_commands {
53                lines.push(format!("  {} — {}", cmd.usage, cmd.description));
54            }
55
56            Ok(PluginResult::Reply(lines.join("\n")))
57        })
58    }
59}
60
61/// Format detailed help for a single command, including typed parameter info.
62fn format_command_help(cmd: &CommandInfo) -> String {
63    let mut lines = vec![cmd.usage.clone(), format!("  {}", cmd.description)];
64    if !cmd.params.is_empty() {
65        lines.push("  parameters:".to_owned());
66        for p in &cmd.params {
67            let req = if p.required { "required" } else { "optional" };
68            let type_hint = match &p.param_type {
69                ParamType::Text => "text".to_owned(),
70                ParamType::Username => "username".to_owned(),
71                ParamType::Number { min, max } => match (min, max) {
72                    (Some(lo), Some(hi)) => format!("number ({lo}..{hi})"),
73                    (Some(lo), None) => format!("number ({lo}..)"),
74                    (None, Some(hi)) => format!("number (..{hi})"),
75                    (None, None) => "number".to_owned(),
76                },
77                ParamType::Choice(values) => {
78                    format!("one of: {}", values.join(", "))
79                }
80            };
81            lines.push(format!(
82                "    <{}> — {} [{}] {}",
83                p.name, p.description, req, type_hint
84            ));
85        }
86    }
87    lines.join("\n")
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93    use crate::plugin::{
94        ChatWriter, HistoryReader, ParamSchema, ParamType, RoomMetadata, UserInfo,
95    };
96    use chrono::Utc;
97    use std::sync::{atomic::AtomicU64, Arc};
98    use tempfile::NamedTempFile;
99    use tokio::sync::Mutex;
100
101    fn make_test_context(params: Vec<String>, commands: Vec<CommandInfo>) -> CommandContext {
102        let tmp = NamedTempFile::new().unwrap();
103        let clients = Arc::new(Mutex::new(std::collections::HashMap::new()));
104        let chat_path = Arc::new(tmp.path().to_path_buf());
105        let room_id = Arc::new("test".to_owned());
106        let seq = Arc::new(AtomicU64::new(0));
107
108        CommandContext {
109            command: "help".to_owned(),
110            params,
111            sender: "alice".to_owned(),
112            room_id: "test".to_owned(),
113            message_id: "msg-1".to_owned(),
114            timestamp: Utc::now(),
115            history: HistoryReader::new(tmp.path(), "alice"),
116            writer: ChatWriter::new(&clients, &chat_path, &room_id, &seq, "help"),
117            metadata: RoomMetadata {
118                online_users: vec![UserInfo {
119                    username: "alice".to_owned(),
120                    status: String::new(),
121                }],
122                host: Some("alice".to_owned()),
123                message_count: 0,
124            },
125            available_commands: commands,
126        }
127    }
128
129    #[tokio::test]
130    async fn help_no_args_lists_all_commands() {
131        let commands = vec![
132            CommandInfo {
133                name: "stats".to_owned(),
134                description: "Show stats".to_owned(),
135                usage: "/stats [N]".to_owned(),
136                params: vec![],
137            },
138            CommandInfo {
139                name: "help".to_owned(),
140                description: "Show help".to_owned(),
141                usage: "/help [cmd]".to_owned(),
142                params: vec![],
143            },
144        ];
145        let ctx = make_test_context(vec![], commands);
146        let result = HelpPlugin.handle(ctx).await.unwrap();
147        let PluginResult::Reply(text) = result else {
148            panic!("expected Reply");
149        };
150        assert!(text.contains("available commands:"));
151        assert!(text.contains("/who"));
152        assert!(text.contains("/stats"));
153        assert!(text.contains("/help"));
154    }
155
156    #[tokio::test]
157    async fn help_specific_plugin_command() {
158        let commands = vec![CommandInfo {
159            name: "stats".to_owned(),
160            description: "Show stats".to_owned(),
161            usage: "/stats [N]".to_owned(),
162            params: vec![],
163        }];
164        let ctx = make_test_context(vec!["stats".to_owned()], commands);
165        let result = HelpPlugin.handle(ctx).await.unwrap();
166        let PluginResult::Reply(text) = result else {
167            panic!("expected Reply");
168        };
169        assert!(text.contains("/stats [N]"));
170        assert!(text.contains("Show stats"));
171    }
172
173    #[tokio::test]
174    async fn help_specific_builtin_command() {
175        let ctx = make_test_context(vec!["who".to_owned()], vec![]);
176        let result = HelpPlugin.handle(ctx).await.unwrap();
177        let PluginResult::Reply(text) = result else {
178            panic!("expected Reply");
179        };
180        assert!(text.contains("/who"));
181        assert!(text.contains("List users in the room"));
182    }
183
184    #[tokio::test]
185    async fn help_unknown_command() {
186        let ctx = make_test_context(vec!["nonexistent".to_owned()], vec![]);
187        let result = HelpPlugin.handle(ctx).await.unwrap();
188        let PluginResult::Reply(text) = result else {
189            panic!("expected Reply");
190        };
191        assert!(text.contains("unknown command: /nonexistent"));
192    }
193
194    #[tokio::test]
195    async fn help_strips_leading_slash_from_arg() {
196        let commands = vec![CommandInfo {
197            name: "stats".to_owned(),
198            description: "Show stats".to_owned(),
199            usage: "/stats [N]".to_owned(),
200            params: vec![ParamSchema {
201                name: "count".to_owned(),
202                param_type: ParamType::Number {
203                    min: Some(1),
204                    max: None,
205                },
206                required: false,
207                description: "Number of messages".to_owned(),
208            }],
209        }];
210        let ctx = make_test_context(vec!["/stats".to_owned()], commands);
211        let result = HelpPlugin.handle(ctx).await.unwrap();
212        let PluginResult::Reply(text) = result else {
213            panic!("expected Reply");
214        };
215        assert!(
216            text.contains("/stats [N]"),
217            "should find stats even with leading /"
218        );
219    }
220
221    #[tokio::test]
222    async fn help_specific_command_shows_param_info() {
223        let commands = vec![CommandInfo {
224            name: "stats".to_owned(),
225            description: "Show stats".to_owned(),
226            usage: "/stats [N]".to_owned(),
227            params: vec![ParamSchema {
228                name: "count".to_owned(),
229                param_type: ParamType::Choice(vec![
230                    "10".to_owned(),
231                    "25".to_owned(),
232                    "50".to_owned(),
233                ]),
234                required: false,
235                description: "Number of messages".to_owned(),
236            }],
237        }];
238        let ctx = make_test_context(vec!["stats".to_owned()], commands);
239        let result = HelpPlugin.handle(ctx).await.unwrap();
240        let PluginResult::Reply(text) = result else {
241            panic!("expected Reply");
242        };
243        assert!(text.contains("parameters:"), "should show param section");
244        assert!(text.contains("<count>"), "should show param name");
245        assert!(text.contains("optional"), "should show required flag");
246        assert!(text.contains("one of:"), "should show choices");
247    }
248
249    #[tokio::test]
250    async fn help_builtin_command_shows_param_info() {
251        // /kick is a built-in with a Username param
252        let ctx = make_test_context(vec!["kick".to_owned()], vec![]);
253        let result = HelpPlugin.handle(ctx).await.unwrap();
254        let PluginResult::Reply(text) = result else {
255            panic!("expected Reply");
256        };
257        assert!(text.contains("parameters:"));
258        assert!(text.contains("username"));
259        assert!(text.contains("required"));
260    }
261}