1use super::{BoxFuture, CommandContext, CommandInfo, ParamType, 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 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 let target = target.strip_prefix('/').unwrap_or(target);
33
34 if let Some(cmd) = ctx.available_commands.iter().find(|c| c.name == target) {
36 return Ok(PluginResult::Reply(format_command_help(cmd)));
37 }
38 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 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
61fn 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 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}