Skip to main content

room_cli/plugin/
mod.rs

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