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    "unsubscribe",
345    "subscriptions",
346];
347
348/// Central registry of plugins. The broker uses this to dispatch `/` commands.
349pub struct PluginRegistry {
350    plugins: Vec<Box<dyn Plugin>>,
351    /// command_name → index into `plugins`.
352    command_map: HashMap<String, usize>,
353}
354
355impl PluginRegistry {
356    pub fn new() -> Self {
357        Self {
358            plugins: Vec::new(),
359            command_map: HashMap::new(),
360        }
361    }
362
363    /// Register a plugin. Returns an error if any command name collides with
364    /// a built-in command or another plugin's command.
365    pub fn register(&mut self, plugin: Box<dyn Plugin>) -> anyhow::Result<()> {
366        let idx = self.plugins.len();
367        for cmd in plugin.commands() {
368            if RESERVED_COMMANDS.contains(&cmd.name.as_str()) {
369                anyhow::bail!(
370                    "plugin '{}' cannot register command '{}': reserved by built-in",
371                    plugin.name(),
372                    cmd.name
373                );
374            }
375            if let Some(&existing_idx) = self.command_map.get(&cmd.name) {
376                anyhow::bail!(
377                    "plugin '{}' cannot register command '{}': already registered by '{}'",
378                    plugin.name(),
379                    cmd.name,
380                    self.plugins[existing_idx].name()
381                );
382            }
383            self.command_map.insert(cmd.name.clone(), idx);
384        }
385        self.plugins.push(plugin);
386        Ok(())
387    }
388
389    /// Look up which plugin handles a command name.
390    pub fn resolve(&self, command: &str) -> Option<&dyn Plugin> {
391        self.command_map
392            .get(command)
393            .map(|&idx| self.plugins[idx].as_ref())
394    }
395
396    /// All registered commands across all plugins.
397    pub fn all_commands(&self) -> Vec<CommandInfo> {
398        self.plugins.iter().flat_map(|p| p.commands()).collect()
399    }
400
401    /// Notify all registered plugins that a user has joined the room.
402    ///
403    /// Calls [`Plugin::on_user_join`] on every plugin in registration order.
404    pub fn notify_join(&self, user: &str) {
405        for plugin in &self.plugins {
406            plugin.on_user_join(user);
407        }
408    }
409
410    /// Notify all registered plugins that a user has left the room.
411    ///
412    /// Calls [`Plugin::on_user_leave`] on every plugin in registration order.
413    pub fn notify_leave(&self, user: &str) {
414        for plugin in &self.plugins {
415            plugin.on_user_leave(user);
416        }
417    }
418
419    /// Completions for a specific command at a given argument position,
420    /// derived from the parameter schema.
421    ///
422    /// Returns `Choice` values for `ParamType::Choice` parameters, or an
423    /// empty vec for freeform/username/number parameters.
424    pub fn completions_for(&self, command: &str, arg_pos: usize) -> Vec<String> {
425        self.all_commands()
426            .iter()
427            .find(|c| c.name == command)
428            .and_then(|c| c.params.get(arg_pos))
429            .map(|p| match &p.param_type {
430                ParamType::Choice(values) => values.clone(),
431                _ => vec![],
432            })
433            .unwrap_or_default()
434    }
435}
436
437impl Default for PluginRegistry {
438    fn default() -> Self {
439        Self::new()
440    }
441}
442
443// ── Built-in command schemas ───────────────────────────────────────────────
444
445/// Returns [`CommandInfo`] schemas for all built-in commands (those handled
446/// directly by the broker, not by plugins). Used by the TUI palette and
447/// `/help` to show a complete command list with typed parameter metadata.
448pub fn builtin_command_infos() -> Vec<CommandInfo> {
449    vec![
450        CommandInfo {
451            name: "dm".to_owned(),
452            description: "Send a private message".to_owned(),
453            usage: "/dm <user> <message>".to_owned(),
454            params: vec![
455                ParamSchema {
456                    name: "user".to_owned(),
457                    param_type: ParamType::Username,
458                    required: true,
459                    description: "Recipient username".to_owned(),
460                },
461                ParamSchema {
462                    name: "message".to_owned(),
463                    param_type: ParamType::Text,
464                    required: true,
465                    description: "Message content".to_owned(),
466                },
467            ],
468        },
469        CommandInfo {
470            name: "claim".to_owned(),
471            description: "Claim a task".to_owned(),
472            usage: "/claim <task>".to_owned(),
473            params: vec![ParamSchema {
474                name: "task".to_owned(),
475                param_type: ParamType::Text,
476                required: true,
477                description: "Task description".to_owned(),
478            }],
479        },
480        CommandInfo {
481            name: "unclaim".to_owned(),
482            description: "Release your current task claim".to_owned(),
483            usage: "/unclaim".to_owned(),
484            params: vec![],
485        },
486        CommandInfo {
487            name: "claimed".to_owned(),
488            description: "Show the task claim board".to_owned(),
489            usage: "/claimed".to_owned(),
490            params: vec![],
491        },
492        CommandInfo {
493            name: "reply".to_owned(),
494            description: "Reply to a message".to_owned(),
495            usage: "/reply <id> <message>".to_owned(),
496            params: vec![
497                ParamSchema {
498                    name: "id".to_owned(),
499                    param_type: ParamType::Text,
500                    required: true,
501                    description: "Message ID to reply to".to_owned(),
502                },
503                ParamSchema {
504                    name: "message".to_owned(),
505                    param_type: ParamType::Text,
506                    required: true,
507                    description: "Reply content".to_owned(),
508                },
509            ],
510        },
511        CommandInfo {
512            name: "set_status".to_owned(),
513            description: "Set your presence status".to_owned(),
514            usage: "/set_status <status>".to_owned(),
515            params: vec![ParamSchema {
516                name: "status".to_owned(),
517                param_type: ParamType::Text,
518                required: false,
519                description: "Status text (omit to clear)".to_owned(),
520            }],
521        },
522        CommandInfo {
523            name: "who".to_owned(),
524            description: "List users in the room".to_owned(),
525            usage: "/who".to_owned(),
526            params: vec![],
527        },
528        CommandInfo {
529            name: "kick".to_owned(),
530            description: "Kick a user from the room".to_owned(),
531            usage: "/kick <user>".to_owned(),
532            params: vec![ParamSchema {
533                name: "user".to_owned(),
534                param_type: ParamType::Username,
535                required: true,
536                description: "User to kick (host only)".to_owned(),
537            }],
538        },
539        CommandInfo {
540            name: "reauth".to_owned(),
541            description: "Invalidate a user's token".to_owned(),
542            usage: "/reauth <user>".to_owned(),
543            params: vec![ParamSchema {
544                name: "user".to_owned(),
545                param_type: ParamType::Username,
546                required: true,
547                description: "User to reauth (host only)".to_owned(),
548            }],
549        },
550        CommandInfo {
551            name: "clear-tokens".to_owned(),
552            description: "Revoke all session tokens".to_owned(),
553            usage: "/clear-tokens".to_owned(),
554            params: vec![],
555        },
556        CommandInfo {
557            name: "exit".to_owned(),
558            description: "Shut down the broker".to_owned(),
559            usage: "/exit".to_owned(),
560            params: vec![],
561        },
562        CommandInfo {
563            name: "clear".to_owned(),
564            description: "Clear the room history".to_owned(),
565            usage: "/clear".to_owned(),
566            params: vec![],
567        },
568        CommandInfo {
569            name: "room-info".to_owned(),
570            description: "Show room visibility, config, and member count".to_owned(),
571            usage: "/room-info".to_owned(),
572            params: vec![],
573        },
574        CommandInfo {
575            name: "subscribe".to_owned(),
576            description: "Subscribe to this room".to_owned(),
577            usage: "/subscribe [tier]".to_owned(),
578            params: vec![ParamSchema {
579                name: "tier".to_owned(),
580                param_type: ParamType::Choice(vec!["full".to_owned(), "mentions_only".to_owned()]),
581                required: false,
582                description: "Subscription tier (default: full)".to_owned(),
583            }],
584        },
585        CommandInfo {
586            name: "unsubscribe".to_owned(),
587            description: "Unsubscribe from this room".to_owned(),
588            usage: "/unsubscribe".to_owned(),
589            params: vec![],
590        },
591        CommandInfo {
592            name: "subscriptions".to_owned(),
593            description: "List subscription tiers for this room".to_owned(),
594            usage: "/subscriptions".to_owned(),
595            params: vec![],
596        },
597    ]
598}
599
600/// Returns command schemas for all known commands: built-ins + default plugins.
601///
602/// Used by the TUI to build its command palette at startup without needing
603/// access to the broker's `PluginRegistry`.
604pub fn all_known_commands() -> Vec<CommandInfo> {
605    let mut cmds = builtin_command_infos();
606    cmds.extend(help::HelpPlugin.commands());
607    cmds.extend(stats::StatsPlugin.commands());
608    cmds
609}
610
611// ── Tests ───────────────────────────────────────────────────────────────────
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    struct DummyPlugin {
618        name: &'static str,
619        cmd: &'static str,
620    }
621
622    impl Plugin for DummyPlugin {
623        fn name(&self) -> &str {
624            self.name
625        }
626
627        fn commands(&self) -> Vec<CommandInfo> {
628            vec![CommandInfo {
629                name: self.cmd.to_owned(),
630                description: "dummy".to_owned(),
631                usage: format!("/{}", self.cmd),
632                params: vec![],
633            }]
634        }
635
636        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
637            Box::pin(async { Ok(PluginResult::Reply("dummy".to_owned())) })
638        }
639    }
640
641    #[test]
642    fn registry_register_and_resolve() {
643        let mut reg = PluginRegistry::new();
644        reg.register(Box::new(DummyPlugin {
645            name: "test",
646            cmd: "foo",
647        }))
648        .unwrap();
649        assert!(reg.resolve("foo").is_some());
650        assert!(reg.resolve("bar").is_none());
651    }
652
653    #[test]
654    fn registry_rejects_reserved_command() {
655        let mut reg = PluginRegistry::new();
656        let result = reg.register(Box::new(DummyPlugin {
657            name: "bad",
658            cmd: "kick",
659        }));
660        assert!(result.is_err());
661        let err = result.unwrap_err().to_string();
662        assert!(err.contains("reserved by built-in"));
663    }
664
665    #[test]
666    fn registry_rejects_duplicate_command() {
667        let mut reg = PluginRegistry::new();
668        reg.register(Box::new(DummyPlugin {
669            name: "first",
670            cmd: "foo",
671        }))
672        .unwrap();
673        let result = reg.register(Box::new(DummyPlugin {
674            name: "second",
675            cmd: "foo",
676        }));
677        assert!(result.is_err());
678        let err = result.unwrap_err().to_string();
679        assert!(err.contains("already registered by 'first'"));
680    }
681
682    #[test]
683    fn registry_all_commands_lists_everything() {
684        let mut reg = PluginRegistry::new();
685        reg.register(Box::new(DummyPlugin {
686            name: "a",
687            cmd: "alpha",
688        }))
689        .unwrap();
690        reg.register(Box::new(DummyPlugin {
691            name: "b",
692            cmd: "beta",
693        }))
694        .unwrap();
695        let cmds = reg.all_commands();
696        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
697        assert!(names.contains(&"alpha"));
698        assert!(names.contains(&"beta"));
699        assert_eq!(names.len(), 2);
700    }
701
702    #[test]
703    fn registry_completions_for_returns_choice_values() {
704        let mut reg = PluginRegistry::new();
705        reg.register(Box::new({
706            struct CompPlugin;
707            impl Plugin for CompPlugin {
708                fn name(&self) -> &str {
709                    "comp"
710                }
711                fn commands(&self) -> Vec<CommandInfo> {
712                    vec![CommandInfo {
713                        name: "test".to_owned(),
714                        description: "test".to_owned(),
715                        usage: "/test".to_owned(),
716                        params: vec![ParamSchema {
717                            name: "count".to_owned(),
718                            param_type: ParamType::Choice(vec!["10".to_owned(), "20".to_owned()]),
719                            required: false,
720                            description: "Number of items".to_owned(),
721                        }],
722                    }]
723                }
724                fn handle(
725                    &self,
726                    _ctx: CommandContext,
727                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
728                    Box::pin(async { Ok(PluginResult::Handled) })
729                }
730            }
731            CompPlugin
732        }))
733        .unwrap();
734        let completions = reg.completions_for("test", 0);
735        assert_eq!(completions, vec!["10", "20"]);
736        assert!(reg.completions_for("test", 1).is_empty());
737        assert!(reg.completions_for("nonexistent", 0).is_empty());
738    }
739
740    #[test]
741    fn registry_completions_for_non_choice_returns_empty() {
742        let mut reg = PluginRegistry::new();
743        reg.register(Box::new({
744            struct TextPlugin;
745            impl Plugin for TextPlugin {
746                fn name(&self) -> &str {
747                    "text"
748                }
749                fn commands(&self) -> Vec<CommandInfo> {
750                    vec![CommandInfo {
751                        name: "echo".to_owned(),
752                        description: "echo".to_owned(),
753                        usage: "/echo".to_owned(),
754                        params: vec![ParamSchema {
755                            name: "msg".to_owned(),
756                            param_type: ParamType::Text,
757                            required: true,
758                            description: "Message".to_owned(),
759                        }],
760                    }]
761                }
762                fn handle(
763                    &self,
764                    _ctx: CommandContext,
765                ) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
766                    Box::pin(async { Ok(PluginResult::Handled) })
767                }
768            }
769            TextPlugin
770        }))
771        .unwrap();
772        // Text params produce no completions
773        assert!(reg.completions_for("echo", 0).is_empty());
774    }
775
776    #[test]
777    fn registry_rejects_all_reserved_commands() {
778        for &reserved in RESERVED_COMMANDS {
779            let mut reg = PluginRegistry::new();
780            let result = reg.register(Box::new(DummyPlugin {
781                name: "bad",
782                cmd: reserved,
783            }));
784            assert!(
785                result.is_err(),
786                "should reject reserved command '{reserved}'"
787            );
788        }
789    }
790
791    // ── ParamSchema / ParamType tests ───────────────────────────────────────
792
793    #[test]
794    fn param_type_choice_equality() {
795        let a = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
796        let b = ParamType::Choice(vec!["x".to_owned(), "y".to_owned()]);
797        assert_eq!(a, b);
798        let c = ParamType::Choice(vec!["x".to_owned()]);
799        assert_ne!(a, c);
800    }
801
802    #[test]
803    fn param_type_number_equality() {
804        let a = ParamType::Number {
805            min: Some(1),
806            max: Some(100),
807        };
808        let b = ParamType::Number {
809            min: Some(1),
810            max: Some(100),
811        };
812        assert_eq!(a, b);
813        let c = ParamType::Number {
814            min: None,
815            max: None,
816        };
817        assert_ne!(a, c);
818    }
819
820    #[test]
821    fn param_type_variants_are_distinct() {
822        assert_ne!(ParamType::Text, ParamType::Username);
823        assert_ne!(
824            ParamType::Text,
825            ParamType::Number {
826                min: None,
827                max: None
828            }
829        );
830        assert_ne!(ParamType::Text, ParamType::Choice(vec!["a".to_owned()]));
831    }
832
833    // ── builtin_command_infos tests ───────────────────────────────────────
834
835    #[test]
836    fn builtin_command_infos_covers_all_expected_commands() {
837        let cmds = builtin_command_infos();
838        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
839        for expected in &[
840            "dm",
841            "claim",
842            "unclaim",
843            "claimed",
844            "reply",
845            "set_status",
846            "who",
847            "kick",
848            "reauth",
849            "clear-tokens",
850            "exit",
851            "clear",
852            "room-info",
853            "subscribe",
854            "unsubscribe",
855            "subscriptions",
856        ] {
857            assert!(
858                names.contains(expected),
859                "missing built-in command: {expected}"
860            );
861        }
862    }
863
864    #[test]
865    fn builtin_command_infos_dm_has_username_param() {
866        let cmds = builtin_command_infos();
867        let dm = cmds.iter().find(|c| c.name == "dm").unwrap();
868        assert_eq!(dm.params.len(), 2);
869        assert_eq!(dm.params[0].param_type, ParamType::Username);
870        assert!(dm.params[0].required);
871        assert_eq!(dm.params[1].param_type, ParamType::Text);
872    }
873
874    #[test]
875    fn builtin_command_infos_kick_has_username_param() {
876        let cmds = builtin_command_infos();
877        let kick = cmds.iter().find(|c| c.name == "kick").unwrap();
878        assert_eq!(kick.params.len(), 1);
879        assert_eq!(kick.params[0].param_type, ParamType::Username);
880        assert!(kick.params[0].required);
881    }
882
883    #[test]
884    fn builtin_command_infos_set_status_is_optional() {
885        let cmds = builtin_command_infos();
886        let ss = cmds.iter().find(|c| c.name == "set_status").unwrap();
887        assert_eq!(ss.params.len(), 1);
888        assert!(!ss.params[0].required);
889    }
890
891    #[test]
892    fn builtin_command_infos_who_has_no_params() {
893        let cmds = builtin_command_infos();
894        let who = cmds.iter().find(|c| c.name == "who").unwrap();
895        assert!(who.params.is_empty());
896    }
897
898    // ── all_known_commands tests ──────────────────────────────────────────
899
900    #[test]
901    fn all_known_commands_includes_builtins_and_plugins() {
902        let cmds = all_known_commands();
903        let names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
904        // Built-ins
905        assert!(names.contains(&"dm"));
906        assert!(names.contains(&"who"));
907        assert!(names.contains(&"kick"));
908        // Plugins
909        assert!(names.contains(&"help"));
910        assert!(names.contains(&"stats"));
911    }
912
913    #[test]
914    fn all_known_commands_no_duplicates() {
915        let cmds = all_known_commands();
916        let mut names: Vec<&str> = cmds.iter().map(|c| c.name.as_str()).collect();
917        let before = names.len();
918        names.sort();
919        names.dedup();
920        assert_eq!(before, names.len(), "duplicate command names found");
921    }
922
923    #[tokio::test]
924    async fn history_reader_filters_dms() {
925        let tmp = tempfile::NamedTempFile::new().unwrap();
926        let path = tmp.path();
927
928        // Write a DM between alice and bob, and a public message
929        let dm = crate::message::make_dm("r", "alice", "bob", "secret");
930        let public = crate::message::make_message("r", "carol", "hello all");
931        history::append(path, &dm).await.unwrap();
932        history::append(path, &public).await.unwrap();
933
934        // alice sees both
935        let reader_alice = HistoryReader::new(path, "alice");
936        let msgs = reader_alice.all().await.unwrap();
937        assert_eq!(msgs.len(), 2);
938
939        // carol sees only the public message
940        let reader_carol = HistoryReader::new(path, "carol");
941        let msgs = reader_carol.all().await.unwrap();
942        assert_eq!(msgs.len(), 1);
943        assert_eq!(msgs[0].user(), "carol");
944    }
945
946    #[tokio::test]
947    async fn history_reader_tail_and_count() {
948        let tmp = tempfile::NamedTempFile::new().unwrap();
949        let path = tmp.path();
950
951        for i in 0..5 {
952            history::append(
953                path,
954                &crate::message::make_message("r", "u", format!("msg {i}")),
955            )
956            .await
957            .unwrap();
958        }
959
960        let reader = HistoryReader::new(path, "u");
961        assert_eq!(reader.count().await.unwrap(), 5);
962
963        let tail = reader.tail(3).await.unwrap();
964        assert_eq!(tail.len(), 3);
965    }
966
967    #[tokio::test]
968    async fn history_reader_since() {
969        let tmp = tempfile::NamedTempFile::new().unwrap();
970        let path = tmp.path();
971
972        let msg1 = crate::message::make_message("r", "u", "first");
973        let msg2 = crate::message::make_message("r", "u", "second");
974        let msg3 = crate::message::make_message("r", "u", "third");
975        let id1 = msg1.id().to_owned();
976        history::append(path, &msg1).await.unwrap();
977        history::append(path, &msg2).await.unwrap();
978        history::append(path, &msg3).await.unwrap();
979
980        let reader = HistoryReader::new(path, "u");
981        let since = reader.since(&id1).await.unwrap();
982        assert_eq!(since.len(), 2);
983    }
984
985    // ── Plugin trait default methods ──────────────────────────────────────
986
987    /// A plugin that only provides a name and handle — no commands override,
988    /// no lifecycle hooks override. Demonstrates the defaults compile and work.
989    struct MinimalPlugin;
990
991    impl Plugin for MinimalPlugin {
992        fn name(&self) -> &str {
993            "minimal"
994        }
995
996        fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
997            Box::pin(async { Ok(PluginResult::Handled) })
998        }
999        // commands(), on_user_join(), on_user_leave() all use defaults
1000    }
1001
1002    #[test]
1003    fn default_commands_returns_empty_vec() {
1004        assert!(MinimalPlugin.commands().is_empty());
1005    }
1006
1007    #[test]
1008    fn default_lifecycle_hooks_are_noop() {
1009        // These should not panic or do anything observable
1010        MinimalPlugin.on_user_join("alice");
1011        MinimalPlugin.on_user_leave("alice");
1012    }
1013
1014    #[test]
1015    fn registry_notify_join_calls_all_plugins() {
1016        use std::sync::{Arc, Mutex};
1017
1018        struct TrackingPlugin {
1019            joined: Arc<Mutex<Vec<String>>>,
1020            left: Arc<Mutex<Vec<String>>>,
1021        }
1022
1023        impl Plugin for TrackingPlugin {
1024            fn name(&self) -> &str {
1025                "tracking"
1026            }
1027
1028            fn handle(&self, _ctx: CommandContext) -> BoxFuture<'_, anyhow::Result<PluginResult>> {
1029                Box::pin(async { Ok(PluginResult::Handled) })
1030            }
1031
1032            fn on_user_join(&self, user: &str) {
1033                self.joined.lock().unwrap().push(user.to_owned());
1034            }
1035
1036            fn on_user_leave(&self, user: &str) {
1037                self.left.lock().unwrap().push(user.to_owned());
1038            }
1039        }
1040
1041        let joined = Arc::new(Mutex::new(Vec::<String>::new()));
1042        let left = Arc::new(Mutex::new(Vec::<String>::new()));
1043        let mut reg = PluginRegistry::new();
1044        reg.register(Box::new(TrackingPlugin {
1045            joined: joined.clone(),
1046            left: left.clone(),
1047        }))
1048        .unwrap();
1049
1050        reg.notify_join("alice");
1051        reg.notify_join("bob");
1052        reg.notify_leave("alice");
1053
1054        assert_eq!(*joined.lock().unwrap(), vec!["alice", "bob"]);
1055        assert_eq!(*left.lock().unwrap(), vec!["alice"]);
1056    }
1057
1058    #[test]
1059    fn registry_notify_join_empty_registry_is_noop() {
1060        let reg = PluginRegistry::new();
1061        // Should not panic with zero plugins
1062        reg.notify_join("alice");
1063        reg.notify_leave("alice");
1064    }
1065
1066    #[test]
1067    fn minimal_plugin_can_be_registered_without_commands() {
1068        let mut reg = PluginRegistry::new();
1069        // MinimalPlugin has no commands, so registration must succeed
1070        // (the only validation in register() is command name conflicts)
1071        reg.register(Box::new(MinimalPlugin)).unwrap();
1072        // It won't show up in resolve() since it has no commands
1073        assert_eq!(reg.all_commands().len(), 0);
1074    }
1075}