Skip to main content

room_cli/plugin/
mod.rs

1pub mod help;
2pub mod stats;
3
4use std::{
5    collections::HashMap,
6    future::Future,
7    path::{Path, PathBuf},
8    pin::Pin,
9    sync::{
10        atomic::{AtomicU64, Ordering},
11        Arc,
12    },
13};
14
15use chrono::{DateTime, Utc};
16
17use crate::{
18    broker::{
19        fanout::broadcast_and_persist,
20        state::{ClientMap, StatusMap},
21    },
22    history,
23    message::{make_system, Message},
24};
25
26/// Boxed future type used by [`Plugin::handle`] for dyn compatibility.
27pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
28
29// ── Plugin trait ────────────────────────────────────────────────────────────
30
31/// A plugin that handles one or more `/` commands.
32///
33/// Implement this trait and register it with [`PluginRegistry`] to add
34/// custom commands to a room broker. The broker dispatches matching
35/// `Message::Command` messages to the plugin's [`handle`](Plugin::handle)
36/// method.
37pub trait Plugin: Send + Sync {
38    /// Unique identifier for this plugin (e.g. `"stats"`, `"help"`).
39    fn name(&self) -> &str;
40
41    /// Commands this plugin handles. Each entry drives `/help` output
42    /// and TUI autocomplete.
43    fn commands(&self) -> Vec<CommandInfo>;
44
45    /// Handle an invocation of one of this plugin's commands.
46    ///
47    /// Returns a boxed future for dyn compatibility (required because the
48    /// registry stores `Box<dyn Plugin>`).
49    fn handle(&self, ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>>;
50}
51
52// ── CommandInfo ─────────────────────────────────────────────────────────────
53
54/// Describes a single command for `/help` and autocomplete.
55#[derive(Debug, Clone)]
56pub struct CommandInfo {
57    /// Command name without the leading `/`.
58    pub name: String,
59    /// One-line description shown in `/help` and autocomplete.
60    pub description: String,
61    /// Usage string (e.g. `"/stats [last N]"`).
62    pub usage: String,
63    /// Typed parameter schemas for validation and autocomplete.
64    pub params: Vec<ParamSchema>,
65}
66
67// ── Typed parameter schema ─────────────────────────────────────────────────
68
69/// Schema for a single command parameter — drives validation, `/help` output,
70/// and TUI argument autocomplete.
71#[derive(Debug, Clone)]
72pub struct ParamSchema {
73    /// Display name (e.g. `"username"`, `"count"`).
74    pub name: String,
75    /// What kind of value this parameter accepts.
76    pub param_type: ParamType,
77    /// Whether the parameter must be provided.
78    pub required: bool,
79    /// One-line description shown in `/help <command>`.
80    pub description: String,
81}
82
83/// The kind of value a parameter accepts.
84#[derive(Debug, Clone, PartialEq)]
85pub enum ParamType {
86    /// Free-form text (no validation beyond presence).
87    Text,
88    /// One of a fixed set of allowed values.
89    Choice(Vec<String>),
90    /// An online username — TUI shows the mention picker.
91    Username,
92    /// An integer, optionally bounded.
93    Number { min: Option<i64>, max: Option<i64> },
94}
95
96// ── CommandContext ───────────────────────────────────────────────────────────
97
98/// Context passed to a plugin's `handle` method.
99pub struct CommandContext {
100    /// The command name that was invoked (without `/`).
101    pub command: String,
102    /// Arguments passed after the command name.
103    pub params: Vec<String>,
104    /// Username of the invoker.
105    pub sender: String,
106    /// Room ID.
107    pub room_id: String,
108    /// Message ID that triggered this command.
109    pub message_id: String,
110    /// Timestamp of the triggering message.
111    pub timestamp: DateTime<Utc>,
112    /// Scoped handle for reading chat history.
113    pub history: HistoryReader,
114    /// Scoped handle for writing back to the chat.
115    pub writer: ChatWriter,
116    /// Snapshot of room metadata.
117    pub metadata: RoomMetadata,
118    /// All registered commands (so `/help` can list them without
119    /// holding a reference to the registry).
120    pub available_commands: Vec<CommandInfo>,
121}
122
123// ── PluginResult ────────────────────────────────────────────────────────────
124
125/// What the broker should do after a plugin handles a command.
126pub enum PluginResult {
127    /// Send a private reply only to the invoker.
128    Reply(String),
129    /// Broadcast a message to the entire room.
130    Broadcast(String),
131    /// Command handled silently (side effects already done via [`ChatWriter`]).
132    Handled,
133}
134
135// ── HistoryReader ───────────────────────────────────────────────────────────
136
137/// Scoped read-only handle to a room's chat history.
138///
139/// Respects DM visibility — a plugin invoked by user X will not see DMs
140/// between Y and Z.
141pub struct HistoryReader {
142    chat_path: PathBuf,
143    viewer: String,
144}
145
146impl HistoryReader {
147    pub(crate) fn new(chat_path: &Path, viewer: &str) -> Self {
148        Self {
149            chat_path: chat_path.to_owned(),
150            viewer: viewer.to_owned(),
151        }
152    }
153
154    /// Load all messages (filtered by DM visibility).
155    pub async fn all(&self) -> anyhow::Result<Vec<Message>> {
156        let all = history::load(&self.chat_path).await?;
157        Ok(self.filter_dms(all))
158    }
159
160    /// Load the last `n` messages (filtered by DM visibility).
161    pub async fn tail(&self, n: usize) -> anyhow::Result<Vec<Message>> {
162        let all = history::tail(&self.chat_path, n).await?;
163        Ok(self.filter_dms(all))
164    }
165
166    /// Load messages after the one with the given ID (filtered by DM visibility).
167    pub async fn since(&self, message_id: &str) -> anyhow::Result<Vec<Message>> {
168        let all = history::load(&self.chat_path).await?;
169        let start = all
170            .iter()
171            .position(|m| m.id() == message_id)
172            .map(|i| i + 1)
173            .unwrap_or(0);
174        Ok(self.filter_dms(all[start..].to_vec()))
175    }
176
177    /// Count total messages in the chat.
178    pub async fn count(&self) -> anyhow::Result<usize> {
179        let all = history::load(&self.chat_path).await?;
180        Ok(all.len())
181    }
182
183    fn filter_dms(&self, messages: Vec<Message>) -> Vec<Message> {
184        messages
185            .into_iter()
186            .filter(|m| match m {
187                Message::DirectMessage { user, to, .. } => {
188                    user == &self.viewer || to == &self.viewer
189                }
190                _ => true,
191            })
192            .collect()
193    }
194}
195
196// ── ChatWriter ──────────────────────────────────────────────────────────────
197
198/// Short-lived scoped handle for a plugin to write messages to the chat.
199///
200/// Posts as `plugin:<name>` — plugins cannot impersonate users. The writer
201/// is valid only for the duration of [`Plugin::handle`].
202pub struct ChatWriter {
203    clients: ClientMap,
204    chat_path: Arc<PathBuf>,
205    room_id: Arc<String>,
206    seq_counter: Arc<AtomicU64>,
207    /// Identity the writer posts as (e.g. `"plugin:stats"`).
208    identity: String,
209}
210
211impl ChatWriter {
212    pub(crate) fn new(
213        clients: &ClientMap,
214        chat_path: &Arc<PathBuf>,
215        room_id: &Arc<String>,
216        seq_counter: &Arc<AtomicU64>,
217        plugin_name: &str,
218    ) -> Self {
219        Self {
220            clients: clients.clone(),
221            chat_path: chat_path.clone(),
222            room_id: room_id.clone(),
223            seq_counter: seq_counter.clone(),
224            identity: format!("plugin:{plugin_name}"),
225        }
226    }
227
228    /// Broadcast a system message to all connected clients and persist to history.
229    pub async fn broadcast(&self, content: &str) -> anyhow::Result<()> {
230        let msg = make_system(&self.room_id, &self.identity, content);
231        broadcast_and_persist(&msg, &self.clients, &self.chat_path, &self.seq_counter).await?;
232        Ok(())
233    }
234
235    /// Send a private system message only to a specific user.
236    pub async fn reply_to(&self, username: &str, content: &str) -> anyhow::Result<()> {
237        let msg = make_system(&self.room_id, &self.identity, content);
238        let seq = self.seq_counter.fetch_add(1, Ordering::SeqCst) + 1;
239        let mut msg = msg;
240        msg.set_seq(seq);
241        history::append(&self.chat_path, &msg).await?;
242
243        let line = format!("{}\n", serde_json::to_string(&msg)?);
244        let map = self.clients.lock().await;
245        for (uname, tx) in map.values() {
246            if uname == username {
247                let _ = tx.send(line.clone());
248            }
249        }
250        Ok(())
251    }
252}
253
254// ── RoomMetadata ────────────────────────────────────────────────────────────
255
256/// Frozen snapshot of room state for plugin consumption.
257pub struct RoomMetadata {
258    /// Users currently online with their status.
259    pub online_users: Vec<UserInfo>,
260    /// Username of the room host.
261    pub host: Option<String>,
262    /// Total messages in the chat file.
263    pub message_count: usize,
264}
265
266/// A user's online presence.
267pub struct UserInfo {
268    pub username: String,
269    pub status: String,
270}
271
272impl RoomMetadata {
273    pub(crate) async fn snapshot(
274        status_map: &StatusMap,
275        host_user: &Arc<tokio::sync::Mutex<Option<String>>>,
276        chat_path: &Path,
277    ) -> Self {
278        let map = status_map.lock().await;
279        let online_users: Vec<UserInfo> = map
280            .iter()
281            .map(|(u, s)| UserInfo {
282                username: u.clone(),
283                status: s.clone(),
284            })
285            .collect();
286        drop(map);
287
288        let host = host_user.lock().await.clone();
289
290        let message_count = history::load(chat_path)
291            .await
292            .map(|msgs| msgs.len())
293            .unwrap_or(0);
294
295        Self {
296            online_users,
297            host,
298            message_count,
299        }
300    }
301}
302
303// ── PluginRegistry ──────────────────────────────────────────────────────────
304
305/// Built-in command names that plugins may not override.
306const RESERVED_COMMANDS: &[&str] = &[
307    "set_status",
308    "who",
309    "kick",
310    "reauth",
311    "clear-tokens",
312    "dm",
313    "claim",
314    "unclaim",
315    "claimed",
316    "reply",
317    "room-info",
318    "exit",
319    "clear",
320];
321
322/// Central registry of plugins. The broker uses this to dispatch `/` commands.
323pub struct PluginRegistry {
324    plugins: Vec<Box<dyn Plugin>>,
325    /// command_name → index into `plugins`.
326    command_map: HashMap<String, usize>,
327}
328
329impl PluginRegistry {
330    pub fn new() -> Self {
331        Self {
332            plugins: Vec::new(),
333            command_map: HashMap::new(),
334        }
335    }
336
337    /// Register a plugin. Returns an error if any command name collides with
338    /// a built-in command or another plugin's command.
339    pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
340        let idx = self.plugins.len();
341        for cmd in plugin.commands() {
342            if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
343                anyhow::bail!(
344                    "plugin '{}' cannot register command '{}': reserved by built-in",
345                    plugin.name(),
346                    cmd.name
347                );
348            }
349            if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
350                anyhow::bail!(
351                    "plugin '{}' cannot register command '{}': already registered by '{}'",
352                    plugin.name(),
353                    cmd.name,
354                    self.plugins[existing_idx].name()
355                );
356            }
357            self.command_map.insert(cmd.name.clone(), idx);
358        }
359        self.plugins.push(plugin);
360        Ok(())
361    }
362
363    /// Look up which plugin handles a command name.
364    pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
365        self.command_map
366            .get(command)
367            .map(|&idx| self.plugins[idx].as_ref())
368    }
369
370    /// All registered commands across all plugins.
371    pub fn all_commands(&self) -> Vec<CommandInfo> {
372        self.plugins.iter().flat_map(|p| p.commands()).collect()
373    }
374
375    /// Completions for a specific command at a given argument position,
376    /// derived from the parameter schema.
377    ///
378    /// Returns `Choice` values for `ParamType::Choice` parameters, or an
379    /// empty vec for freeform/username/number parameters.
380    pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
381        self.all_commands()
382            .iter()
383            .find(|c| c.name == command)
384            .and_then(|c| c.params.get(arg_pos))
385            .map(|p| match &p.param_type {
386                ParamType::Choice(values) => values.clone(),
387                _ => vec![],
388            })
389            .unwrap_or_default()
390    }
391}
392
393impl Default for PluginRegistry {
394    fn default() -> Self {
395        Self::new()
396    }
397}
398
399// ── Built-in command schemas ───────────────────────────────────────────────
400
401/// Returns [`CommandInfo`] schemas for all built-in commands (those handled
402/// directly by the broker, not by plugins). Used by the TUI palette and
403/// `/help` to show a complete command list with typed parameter metadata.
404pub fn builtin_command_infos() -> Vec<CommandInfo> {
405    vec![
406        CommandInfo {
407            name: "dm".to_owned(),
408            description: "Send a private message".to_owned(),
409            usage: "/dm <user> <message>".to_owned(),
410            params: vec![
411                ParamSchema {
412                    name: "user".to_owned(),
413                    param_type: ParamType::Username,
414                    required: true,
415                    description: "Recipient username".to_owned(),
416                },
417                ParamSchema {
418                    name: "message".to_owned(),
419                    param_type: ParamType::Text,
420                    required: true,
421                    description: "Message content".to_owned(),
422                },
423            ],
424        },
425        CommandInfo {
426            name: "claim".to_owned(),
427            description: "Claim a task".to_owned(),
428            usage: "/claim <task>".to_owned(),
429            params: vec![ParamSchema {
430                name: "task".to_owned(),
431                param_type: ParamType::Text,
432                required: true,
433                description: "Task description".to_owned(),
434            }],
435        },
436        CommandInfo {
437            name: "unclaim".to_owned(),
438            description: "Release your current task claim".to_owned(),
439            usage: "/unclaim".to_owned(),
440            params: vec![],
441        },
442        CommandInfo {
443            name: "claimed".to_owned(),
444            description: "Show the task claim board".to_owned(),
445            usage: "/claimed".to_owned(),
446            params: vec![],
447        },
448        CommandInfo {
449            name: "reply".to_owned(),
450            description: "Reply to a message".to_owned(),
451            usage: "/reply <id> <message>".to_owned(),
452            params: vec![
453                ParamSchema {
454                    name: "id".to_owned(),
455                    param_type: ParamType::Text,
456                    required: true,
457                    description: "Message ID to reply to".to_owned(),
458                },
459                ParamSchema {
460                    name: "message".to_owned(),
461                    param_type: ParamType::Text,
462                    required: true,
463                    description: "Reply content".to_owned(),
464                },
465            ],
466        },
467        CommandInfo {
468            name: "set_status".to_owned(),
469            description: "Set your presence status".to_owned(),
470            usage: "/set_status <status>".to_owned(),
471            params: vec![ParamSchema {
472                name: "status".to_owned(),
473                param_type: ParamType::Text,
474                required: false,
475                description: "Status text (omit to clear)".to_owned(),
476            }],
477        },
478        CommandInfo {
479            name: "who".to_owned(),
480            description: "List users in the room".to_owned(),
481            usage: "/who".to_owned(),
482            params: vec![],
483        },
484        CommandInfo {
485            name: "kick".to_owned(),
486            description: "Kick a user from the room".to_owned(),
487            usage: "/kick <user>".to_owned(),
488            params: vec![ParamSchema {
489                name: "user".to_owned(),
490                param_type: ParamType::Username,
491                required: true,
492                description: "User to kick (host only)".to_owned(),
493            }],
494        },
495        CommandInfo {
496            name: "reauth".to_owned(),
497            description: "Invalidate a user's token".to_owned(),
498            usage: "/reauth <user>".to_owned(),
499            params: vec![ParamSchema {
500                name: "user".to_owned(),
501                param_type: ParamType::Username,
502                required: true,
503                description: "User to reauth (host only)".to_owned(),
504            }],
505        },
506        CommandInfo {
507            name: "clear-tokens".to_owned(),
508            description: "Revoke all session tokens".to_owned(),
509            usage: "/clear-tokens".to_owned(),
510            params: vec![],
511        },
512        CommandInfo {
513            name: "exit".to_owned(),
514            description: "Shut down the broker".to_owned(),
515            usage: "/exit".to_owned(),
516            params: vec![],
517        },
518        CommandInfo {
519            name: "clear".to_owned(),
520            description: "Clear the room history".to_owned(),
521            usage: "/clear".to_owned(),
522            params: vec![],
523        },
524        CommandInfo {
525            name: "room-info".to_owned(),
526            description: "Show room visibility, config, and member count".to_owned(),
527            usage: "/room-info".to_owned(),
528            params: vec![],
529        },
530    ]
531}
532
533/// Returns command schemas for all known commands: built-ins + default plugins.
534///
535/// Used by the TUI to build its command palette at startup without needing
536/// access to the broker's `PluginRegistry`.
537pub fn all_known_commands() -> Vec<CommandInfo> {
538    let mut cmds = builtin_command_infos();
539    cmds.extend(help::HelpPlugin.commands());
540    cmds.extend(stats::StatsPlugin.commands());
541    cmds
542}
543
544// ── Tests ───────────────────────────────────────────────────────────────────
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549
550    struct DummyPlugin {
551        name: &'static str,
552        cmd: &'static str,
553    }
554
555    impl Plugin for DummyPlugin {
556        fn name(&self) -> &str {
557            self.name
558        }
559
560        fn commands(&self) -> Vec<CommandInfo> {
561            vec![CommandInfo {
562                name: self.cmd.to_owned(),
563                description: "dummy".to_owned(),
564                usage: format!("/{}", self.cmd),
565                params: vec![],
566            }]
567        }
568
569        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
570            Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
571        }
572    }
573
574    #[test]
575    fn registry_register_and_resolve() {
576        let mut reg = PluginRegistry::new();
577        reg.register(Box::new(DummyPlugin {
578            name: "test",
579            cmd: "foo",
580        }))
581        .unwrap();
582        assert!(reg.resolve("foo").is_some());
583        assert!(reg.resolve("bar").is_none());
584    }
585
586    #[test]
587    fn registry_rejects_reserved_command() {
588        let mut reg = PluginRegistry::new();
589        let result = reg.register(Box::new(DummyPlugin {
590            name: "bad",
591            cmd: "kick",
592        }));
593        assert!(result.is_err());
594        let err = result.unwrap_err().to_string();
595        assert!(err.contains("reserved by built-in"));
596    }
597
598    #[test]
599    fn registry_rejects_duplicate_command() {
600        let mut reg = PluginRegistry::new();
601        reg.register(Box::new(DummyPlugin {
602            name: "first",
603            cmd: "foo",
604        }))
605        .unwrap();
606        let result = reg.register(Box::new(DummyPlugin {
607            name: "second",
608            cmd: "foo",
609        }));
610        assert!(result.is_err());
611        let err = result.unwrap_err().to_string();
612        assert!(err.contains("already registered by 'first'"));
613    }
614
615    #[test]
616    fn registry_all_commands_lists_everything() {
617        let mut reg = PluginRegistry::new();
618        reg.register(Box::new(DummyPlugin {
619            name: "a",
620            cmd: "alpha",
621        }))
622        .unwrap();
623        reg.register(Box::new(DummyPlugin {
624            name: "b",
625            cmd: "beta",
626        }))
627        .unwrap();
628        let cmds = reg.all_commands();
629        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
630        assert!(names.contains(&"alpha"));
631        assert!(names.contains(&"beta"));
632        assert_eq!(names.len(), 2);
633    }
634
635    #[test]
636    fn registry_completions_for_returns_choice_values() {
637        let mut reg = PluginRegistry::new();
638        reg.register(Box::new({
639            struct CompPlugin;
640            impl Plugin for CompPlugin {
641                fn name(&self) -> &str {
642                    "comp"
643                }
644                fn commands(&self) -> Vec<CommandInfo> {
645                    vec![CommandInfo {
646                        name: "test".to_owned(),
647                        description: "test".to_owned(),
648                        usage: "/test".to_owned(),
649                        params: vec![ParamSchema {
650                            name: "count".to_owned(),
651                            param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
652                            required: false,
653                            description: "Number of items".to_owned(),
654                        }],
655                    }]
656                }
657                fn handle(
658                    &self,
659                    _ctx: CommandContext,
660                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
661                    Box::pin(async { Ok(PluginResult::Handled) })
662                }
663            }
664            CompPlugin
665        }))
666        .unwrap();
667        let completions = reg.completions_for("test", 0);
668        assert_eq!(completions, vec!["10", "20"]);
669        assert!(reg.completions_for("test", 1).is_empty());
670        assert!(reg.completions_for("nonexistent", 0).is_empty());
671    }
672
673    #[test]
674    fn registry_completions_for_non_choice_returns_empty() {
675        let mut reg = PluginRegistry::new();
676        reg.register(Box::new({
677            struct TextPlugin;
678            impl Plugin for TextPlugin {
679                fn name(&self) -> &str {
680                    "text"
681                }
682                fn commands(&self) -> Vec<CommandInfo> {
683                    vec![CommandInfo {
684                        name: "echo".to_owned(),
685                        description: "echo".to_owned(),
686                        usage: "/echo".to_owned(),
687                        params: vec![ParamSchema {
688                            name: "msg".to_owned(),
689                            param_type: ParamType::Text,
690                            required: true,
691                            description: "Message".to_owned(),
692                        }],
693                    }]
694                }
695                fn handle(
696                    &self,
697                    _ctx: CommandContext,
698                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
699                    Box::pin(async { Ok(PluginResult::Handled) })
700                }
701            }
702            TextPlugin
703        }))
704        .unwrap();
705        // Text params produce no completions
706        assert!(reg.completions_for("echo", 0).is_empty());
707    }
708
709    #[test]
710    fn registry_rejects_all_reserved_commands() {
711        for &reserved in RESERVED_COMMANDS {
712            let mut reg = PluginRegistry::new();
713            let result = reg.register(Box::new(DummyPlugin {
714                name: "bad",
715                cmd: reserved,
716            }));
717            assert!(
718                result.is_err(),
719                "should reject reserved command '{reserved}'"
720            );
721        }
722    }
723
724    // ── ParamSchema / ParamType tests ───────────────────────────────────────
725
726    #[test]
727    fn param_type_choice_equality() {
728        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
729        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
730        assert_eq!(a, b);
731        let c = ParamType::Choice(vec!["x".to_owned()]);
732        assert_ne!(a, c);
733    }
734
735    #[test]
736    fn param_type_number_equality() {
737        let a = ParamType::Number {
738            min: Some(1),
739            max: Some(100),
740        };
741        let b = ParamType::Number {
742            min: Some(1),
743            max: Some(100),
744        };
745        assert_eq!(a, b);
746        let c = ParamType::Number {
747            min: None,
748            max: None,
749        };
750        assert_ne!(a, c);
751    }
752
753    #[test]
754    fn param_type_variants_are_distinct() {
755        assert_ne!(ParamType::Text, ParamType::Username);
756        assert_ne!(
757            ParamType::Text,
758            ParamType::Number {
759                min: None,
760                max: None
761            }
762        );
763        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
764    }
765
766    // ── builtin_command_infos tests ───────────────────────────────────────
767
768    #[test]
769    fn builtin_command_infos_covers_all_expected_commands() {
770        let cmds = builtin_command_infos();
771        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
772        for expected in &[
773            "dm",
774            "claim",
775            "unclaim",
776            "claimed",
777            "reply",
778            "set_status",
779            "who",
780            "kick",
781            "reauth",
782            "clear-tokens",
783            "exit",
784            "clear",
785            "room-info",
786        ] {
787            assert!(
788                names.contains(expected),
789                "missing built-in command: {expected}"
790            );
791        }
792    }
793
794    #[test]
795    fn builtin_command_infos_dm_has_username_param() {
796        let cmds = builtin_command_infos();
797        let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
798        assert_eq!(dm.params.len(), 2);
799        assert_eq!(dm.params[0].param_type, ParamType::Username);
800        assert!(dm.params[0].required);
801        assert_eq!(dm.params[1].param_type, ParamType::Text);
802    }
803
804    #[test]
805    fn builtin_command_infos_kick_has_username_param() {
806        let cmds = builtin_command_infos();
807        let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
808        assert_eq!(kick.params.len(), 1);
809        assert_eq!(kick.params[0].param_type, ParamType::Username);
810        assert!(kick.params[0].required);
811    }
812
813    #[test]
814    fn builtin_command_infos_set_status_is_optional() {
815        let cmds = builtin_command_infos();
816        let ss = cmds.iter().find(|c| c.name == "set_status").unwrap();
817        assert_eq!(ss.params.len(), 1);
818        assert!(!ss.params[0].required);
819    }
820
821    #[test]
822    fn builtin_command_infos_who_has_no_params() {
823        let cmds = builtin_command_infos();
824        let who = cmds.iter().find(|c| c.name == "who").unwrap();
825        assert!(who.params.is_empty());
826    }
827
828    // ── all_known_commands tests ──────────────────────────────────────────
829
830    #[test]
831    fn all_known_commands_includes_builtins_and_plugins() {
832        let cmds = all_known_commands();
833        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
834        // Built-ins
835        assert!(names.contains(&"dm"));
836        assert!(names.contains(&"who"));
837        assert!(names.contains(&"kick"));
838        // Plugins
839        assert!(names.contains(&"help"));
840        assert!(names.contains(&"stats"));
841    }
842
843    #[test]
844    fn all_known_commands_no_duplicates() {
845        let cmds = all_known_commands();
846        let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
847        let before = names.len();
848        names.sort();
849        names.dedup();
850        assert_eq!(before, names.len(), "duplicate command names found");
851    }
852
853    #[tokio::test]
854    async fn history_reader_filters_dms() {
855        let tmp = tempfile::NamedTempFile::new().unwrap();
856        let path = tmp.path();
857
858        // Write a DM between alice and bob, and a public message
859        let dm = crate::message::make_dm("r", "alice", "bob", "secret");
860        let public = crate::message::make_message("r", "carol", "hello all");
861        history::append(path, &dm).await.unwrap();
862        history::append(path, &public).await.unwrap();
863
864        // alice sees both
865        let reader_alice = HistoryReader::new(path, "alice");
866        let msgs = reader_alice.all().await.unwrap();
867        assert_eq!(msgs.len(), 2);
868
869        // carol sees only the public message
870        let reader_carol = HistoryReader::new(path, "carol");
871        let msgs = reader_carol.all().await.unwrap();
872        assert_eq!(msgs.len(), 1);
873        assert_eq!(msgs[0].user(), "carol");
874    }
875
876    #[tokio::test]
877    async fn history_reader_tail_and_count() {
878        let tmp = tempfile::NamedTempFile::new().unwrap();
879        let path = tmp.path();
880
881        for i in 0..5 {
882            history::append(
883                path,
884                &crate::message::make_message("r", "u", format!("msg {i}")),
885            )
886            .await
887            .unwrap();
888        }
889
890        let reader = HistoryReader::new(path, "u");
891        assert_eq!(reader.count().await.unwrap(), 5);
892
893        let tail = reader.tail(3).await.unwrap();
894        assert_eq!(tail.len(), 3);
895    }
896
897    #[tokio::test]
898    async fn history_reader_since() {
899        let tmp = tempfile::NamedTempFile::new().unwrap();
900        let path = tmp.path();
901
902        let msg1 = crate::message::make_message("r", "u", "first");
903        let msg2 = crate::message::make_message("r", "u", "second");
904        let msg3 = crate::message::make_message("r", "u", "third");
905        let id1 = msg1.id().to_owned();
906        history::append(path, &msg1).await.unwrap();
907        history::append(path, &msg2).await.unwrap();
908        history::append(path, &msg3).await.unwrap();
909
910        let reader = HistoryReader::new(path, "u");
911        let since = reader.since(&id1).await.unwrap();
912        assert_eq!(since.len(), 2);
913    }
914}