Skip to main content

zeph_commands/handlers/
misc.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Miscellaneous utility handlers: `/cache-stats`, `/image`, `/notify-test`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Display tool orchestrator cache statistics.
13pub struct CacheStatsCommand;
14
15impl CommandHandler<CommandContext<'_>> for CacheStatsCommand {
16    fn name(&self) -> &'static str {
17        "/cache-stats"
18    }
19
20    fn description(&self) -> &'static str {
21        "Show tool orchestrator cache statistics"
22    }
23
24    fn category(&self) -> SlashCategory {
25        SlashCategory::Debugging
26    }
27
28    fn requires_auth(&self) -> bool {
29        true
30    }
31
32    fn handle<'a>(
33        &'a self,
34        ctx: &'a mut CommandContext<'_>,
35        _args: &'a str,
36    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
37        use tracing::Instrument as _;
38        let span = tracing::info_span!("commands.cache_stats.handle");
39        Box::pin(
40            async move {
41                let result = ctx.agent.cache_stats();
42                Ok(CommandOutput::Message(result))
43            }
44            .instrument(span),
45        )
46    }
47}
48
49/// Send a test notification via all enabled notification channels.
50pub struct NotifyTestCommand;
51
52impl CommandHandler<CommandContext<'_>> for NotifyTestCommand {
53    fn name(&self) -> &'static str {
54        "/notify-test"
55    }
56
57    fn description(&self) -> &'static str {
58        "Send a test notification via all enabled channels (macOS, webhook)"
59    }
60
61    fn category(&self) -> SlashCategory {
62        SlashCategory::Debugging
63    }
64
65    fn requires_auth(&self) -> bool {
66        true
67    }
68
69    fn handle<'a>(
70        &'a self,
71        ctx: &'a mut CommandContext<'_>,
72        _args: &'a str,
73    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
74        use tracing::Instrument as _;
75        let span = tracing::info_span!("commands.notify_test.handle");
76        Box::pin(
77            async move {
78                let result = ctx.agent.notify_test().await?;
79                Ok(CommandOutput::Message(result))
80            }
81            .instrument(span),
82        )
83    }
84}
85
86/// Attach an image file to the next user message.
87///
88/// `args` must be a non-empty file path. If `args` is empty the handler returns
89/// a usage hint.
90pub struct ImageCommand;
91
92impl CommandHandler<CommandContext<'_>> for ImageCommand {
93    fn name(&self) -> &'static str {
94        "/image"
95    }
96
97    fn description(&self) -> &'static str {
98        "Attach an image to the next message"
99    }
100
101    fn args_hint(&self) -> &'static str {
102        "<path>"
103    }
104
105    fn category(&self) -> SlashCategory {
106        SlashCategory::Integration
107    }
108
109    fn requires_auth(&self) -> bool {
110        true
111    }
112
113    fn handle<'a>(
114        &'a self,
115        ctx: &'a mut CommandContext<'_>,
116        args: &'a str,
117    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
118        use tracing::Instrument as _;
119        let span = tracing::info_span!("commands.image.handle");
120        Box::pin(
121            async move {
122                if args.is_empty() {
123                    return Err(CommandError::new("Usage: /image <path>"));
124                }
125                let result = ctx.agent.load_image(args).await?;
126                Ok(CommandOutput::Message(result))
127            }
128            .instrument(span),
129        )
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use crate::CommandRegistry;
137    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
138    use crate::sink::NullSink;
139    use std::assert_matches;
140
141    #[test]
142    fn cache_stats_name_and_description() {
143        assert_eq!(CacheStatsCommand.name(), "/cache-stats");
144        assert!(!CacheStatsCommand.description().is_empty());
145    }
146
147    #[test]
148    fn notify_test_name_and_description() {
149        assert_eq!(NotifyTestCommand.name(), "/notify-test");
150        assert!(!NotifyTestCommand.description().is_empty());
151    }
152
153    #[test]
154    fn image_name_and_description() {
155        assert_eq!(ImageCommand.name(), "/image");
156        assert!(!ImageCommand.description().is_empty());
157    }
158
159    #[tokio::test]
160    async fn cache_stats_returns_message() {
161        let mut sink = NullSink;
162        let mut debug = MockDebug;
163        let mut messages = MockMessages;
164        let session = MockSession;
165        let mut agent = crate::NullAgent;
166        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
167        let out = CacheStatsCommand.handle(&mut ctx, "").await.unwrap();
168        assert_matches!(out, CommandOutput::Message(_));
169    }
170
171    #[tokio::test]
172    async fn notify_test_returns_message() {
173        let mut sink = NullSink;
174        let mut debug = MockDebug;
175        let mut messages = MockMessages;
176        let session = MockSession;
177        let mut agent = crate::NullAgent;
178        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
179        let out = NotifyTestCommand.handle(&mut ctx, "").await.unwrap();
180        assert_matches!(out, CommandOutput::Message(_));
181    }
182
183    #[tokio::test]
184    async fn image_no_args_returns_error() {
185        let mut sink = NullSink;
186        let mut debug = MockDebug;
187        let mut messages = MockMessages;
188        let session = MockSession;
189        let mut agent = crate::NullAgent;
190        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
191        let err = ImageCommand.handle(&mut ctx, "").await.unwrap_err();
192        assert!(err.to_string().contains("/image"));
193    }
194
195    #[tokio::test]
196    async fn image_with_path_returns_message() {
197        let mut sink = NullSink;
198        let mut debug = MockDebug;
199        let mut messages = MockMessages;
200        let session = MockSession;
201        let mut agent = crate::NullAgent;
202        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
203        let out = ImageCommand
204            .handle(&mut ctx, "/tmp/photo.png")
205            .await
206            .unwrap();
207        assert_matches!(out, CommandOutput::Message(_));
208    }
209
210    #[tokio::test]
211    async fn image_dispatch_allowed_when_trusted() {
212        let mut sink = NullSink;
213        let mut debug = MockDebug;
214        let mut messages = MockMessages;
215        let session = MockSession;
216        let mut agent = crate::NullAgent;
217        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
218
219        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
220        reg.register(ImageCommand);
221
222        let result = reg.dispatch(&mut ctx, "/image photo.png", true).await;
223        assert!(result.unwrap().is_ok());
224    }
225
226    #[tokio::test]
227    async fn image_dispatch_rejected_when_untrusted() {
228        let mut sink = NullSink;
229        let mut debug = MockDebug;
230        let mut messages = MockMessages;
231        let session = MockSession;
232        let mut agent = crate::NullAgent;
233        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
234
235        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
236        reg.register(ImageCommand);
237
238        let result = reg.dispatch(&mut ctx, "/image photo.png", false).await;
239        let err = result.unwrap().unwrap_err();
240        assert!(err.0.contains("trusted"));
241    }
242}