Skip to main content

room_cli/plugin/
mod.rs

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