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