Skip to main content

room_cli/plugin/
help.rs

1use super::{BoxFuture, CommandContext, CommandInfo, 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            completions: vec![],
20        }]
21    }
22
23    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
24        Box::pin(async move {
25            if let Some(target) = ctx.params.first() {
26                // Show help for a specific command
27                let target = target.strip_prefix('/').unwrap_or(target);
28                if let Some(cmd) = ctx.available_commands.iter().find(|c| c.name == target) {
29                    let text = format!("{}\n  {}", cmd.usage, cmd.description);
30                    return Ok(PluginResult::Reply(text));
31                }
32                // Also check built-in commands
33                let builtin = builtin_help(target);
34                if let Some(text) = builtin {
35                    return Ok(PluginResult::Reply(text));
36                }
37                return Ok(PluginResult::Reply(format!("unknown command: /{target}")));
38            }
39
40            // List all commands: built-ins first, then plugins
41            let mut lines = vec![
42                "available commands:".to_owned(),
43                "  /who — show online users".to_owned(),
44                "  /set_status <status> — set your status".to_owned(),
45                "  /kick <user> — kick a user (host only)".to_owned(),
46                "  /reauth <user> — clear a user's token (host only)".to_owned(),
47                "  /clear-tokens — clear all tokens (host only)".to_owned(),
48                "  /clear — clear chat history (host only)".to_owned(),
49                "  /exit — shut down the room (host only)".to_owned(),
50            ];
51            for cmd in &ctx.available_commands {
52                lines.push(format!("  {} — {}", cmd.usage, cmd.description));
53            }
54
55            Ok(PluginResult::Reply(lines.join("\n")))
56        })
57    }
58}
59
60fn builtin_help(cmd: &str) -> Option<String> {
61    match cmd {
62        "who" => Some("/who\n  Show online users and their status".to_owned()),
63        "set_status" => {
64            Some("/set_status <status>\n  Set your status (visible in /who)".to_owned())
65        }
66        "kick" => Some(
67            "/kick <username>\n  Kick a user and invalidate their token (host only)".to_owned(),
68        ),
69        "reauth" => Some(
70            "/reauth <username>\n  Clear a user's token so they can rejoin (host only)".to_owned(),
71        ),
72        "clear-tokens" => {
73            Some("/clear-tokens\n  Clear all tokens; all users must rejoin (host only)".to_owned())
74        }
75        "clear" => Some("/clear\n  Truncate chat history (host only)".to_owned()),
76        "exit" => Some("/exit\n  Shut down the room (host only)".to_owned()),
77        _ => None,
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84    use crate::plugin::{ChatWriter, Completion, HistoryReader, RoomMetadata, UserInfo};
85    use chrono::Utc;
86    use std::sync::{atomic::AtomicU64, Arc};
87    use tempfile::NamedTempFile;
88    use tokio::sync::Mutex;
89
90    fn make_test_context(params: Vec<String>, commands: Vec<CommandInfo>) -> CommandContext {
91        let tmp = NamedTempFile::new().unwrap();
92        let clients = Arc::new(Mutex::new(std::collections::HashMap::new()));
93        let chat_path = Arc::new(tmp.path().to_path_buf());
94        let room_id = Arc::new("test".to_owned());
95        let seq = Arc::new(AtomicU64::new(0));
96
97        CommandContext {
98            command: "help".to_owned(),
99            params,
100            sender: "alice".to_owned(),
101            room_id: "test".to_owned(),
102            message_id: "msg-1".to_owned(),
103            timestamp: Utc::now(),
104            history: HistoryReader::new(tmp.path(), "alice"),
105            writer: ChatWriter::new(&clients, &chat_path, &room_id, &seq, "help"),
106            metadata: RoomMetadata {
107                online_users: vec![UserInfo {
108                    username: "alice".to_owned(),
109                    status: String::new(),
110                }],
111                host: Some("alice".to_owned()),
112                message_count: 0,
113            },
114            available_commands: commands,
115        }
116    }
117
118    #[tokio::test]
119    async fn help_no_args_lists_all_commands() {
120        let commands = vec![
121            CommandInfo {
122                name: "stats".to_owned(),
123                description: "Show stats".to_owned(),
124                usage: "/stats [N]".to_owned(),
125                completions: vec![],
126            },
127            CommandInfo {
128                name: "help".to_owned(),
129                description: "Show help".to_owned(),
130                usage: "/help [cmd]".to_owned(),
131                completions: vec![],
132            },
133        ];
134        let ctx = make_test_context(vec![], commands);
135        let result = HelpPlugin.handle(ctx).await.unwrap();
136        let PluginResult::Reply(text) = result else {
137            panic!("expected Reply");
138        };
139        assert!(text.contains("available commands:"));
140        assert!(text.contains("/who"));
141        assert!(text.contains("/stats"));
142        assert!(text.contains("/help"));
143    }
144
145    #[tokio::test]
146    async fn help_specific_plugin_command() {
147        let commands = vec![CommandInfo {
148            name: "stats".to_owned(),
149            description: "Show stats".to_owned(),
150            usage: "/stats [N]".to_owned(),
151            completions: vec![],
152        }];
153        let ctx = make_test_context(vec!["stats".to_owned()], commands);
154        let result = HelpPlugin.handle(ctx).await.unwrap();
155        let PluginResult::Reply(text) = result else {
156            panic!("expected Reply");
157        };
158        assert!(text.contains("/stats [N]"));
159        assert!(text.contains("Show stats"));
160    }
161
162    #[tokio::test]
163    async fn help_specific_builtin_command() {
164        let ctx = make_test_context(vec!["who".to_owned()], vec![]);
165        let result = HelpPlugin.handle(ctx).await.unwrap();
166        let PluginResult::Reply(text) = result else {
167            panic!("expected Reply");
168        };
169        assert!(text.contains("/who"));
170        assert!(text.contains("Show online users"));
171    }
172
173    #[tokio::test]
174    async fn help_unknown_command() {
175        let ctx = make_test_context(vec!["nonexistent".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("unknown command: /nonexistent"));
181    }
182
183    #[tokio::test]
184    async fn help_strips_leading_slash_from_arg() {
185        let commands = vec![CommandInfo {
186            name: "stats".to_owned(),
187            description: "Show stats".to_owned(),
188            usage: "/stats [N]".to_owned(),
189            completions: vec![Completion {
190                position: 0,
191                values: vec!["10".to_owned()],
192            }],
193        }];
194        let ctx = make_test_context(vec!["/stats".to_owned()], commands);
195        let result = HelpPlugin.handle(ctx).await.unwrap();
196        let PluginResult::Reply(text) = result else {
197            panic!("expected Reply");
198        };
199        assert!(
200            text.contains("/stats [N]"),
201            "should find stats even with leading /"
202        );
203    }
204}