1use super::{BoxFuture, CommandContext, CommandInfo, Plugin, PluginResult};
2
3pub 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 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 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 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}