Skip to main content

zeph_commands/handlers/
debug.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Debug command handlers: `/log`, `/debug-dump`, `/dump-format`.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::CommandHandler;
10use crate::context::CommandContext;
11use crate::{CommandError, CommandOutput, SlashCategory};
12
13/// Show log file path and recent log entries.
14pub struct LogCommand;
15
16impl CommandHandler<CommandContext<'_>> for LogCommand {
17    fn name(&self) -> &'static str {
18        "/log"
19    }
20
21    fn description(&self) -> &'static str {
22        "Toggle verbose log output"
23    }
24
25    fn category(&self) -> SlashCategory {
26        SlashCategory::Debugging
27    }
28
29    fn handle<'a>(
30        &'a self,
31        ctx: &'a mut CommandContext<'_>,
32        _args: &'a str,
33    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
34        Box::pin(async move {
35            let mut out = ctx.debug.log_status();
36            if let Some(tail) = ctx.debug.read_log_tail(20).await {
37                out.push('\n');
38                out.push_str("Recent entries:\n");
39                out.push_str(&ctx.debug.scrub(&tail));
40            }
41            Ok(CommandOutput::Message(out.trim_end().to_owned()))
42        })
43    }
44}
45
46/// Enable or show the status of debug dump output.
47///
48/// With no arguments, reports whether debug dump is active and where.
49/// With a path argument, enables debug dump to that directory.
50pub struct DebugDumpCommand;
51
52impl CommandHandler<CommandContext<'_>> for DebugDumpCommand {
53    fn name(&self) -> &'static str {
54        "/debug-dump"
55    }
56
57    fn description(&self) -> &'static str {
58        "Enable or toggle debug dump output"
59    }
60
61    fn args_hint(&self) -> &'static str {
62        "[path]"
63    }
64
65    fn category(&self) -> SlashCategory {
66        SlashCategory::Debugging
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        Box::pin(async move {
75            if args.is_empty() {
76                let msg = match ctx.debug.dump_status() {
77                    Some(path) => format!("Debug dump active: {path}"),
78                    None => "Debug dump is inactive. Use `/debug-dump <path>` to enable, \
79                         or start with `--debug-dump [dir]`."
80                        .to_owned(),
81                };
82                return Ok(CommandOutput::Message(msg));
83            }
84
85            match ctx.debug.enable_dump(args) {
86                Ok(path) => Ok(CommandOutput::Message(format!(
87                    "Debug dump enabled: {path}"
88                ))),
89                Err(e) => Ok(CommandOutput::Message(format!(
90                    "Failed to enable debug dump: {e}"
91                ))),
92            }
93        })
94    }
95}
96
97/// Switch debug dump format at runtime.
98pub struct DumpFormatCommand;
99
100impl CommandHandler<CommandContext<'_>> for DumpFormatCommand {
101    fn name(&self) -> &'static str {
102        "/dump-format"
103    }
104
105    fn description(&self) -> &'static str {
106        "Switch debug dump format at runtime"
107    }
108
109    fn args_hint(&self) -> &'static str {
110        "<json|raw|trace>"
111    }
112
113    fn category(&self) -> SlashCategory {
114        SlashCategory::Debugging
115    }
116
117    fn handle<'a>(
118        &'a self,
119        ctx: &'a mut CommandContext<'_>,
120        args: &'a str,
121    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
122        Box::pin(async move {
123            if args.is_empty() {
124                return Ok(CommandOutput::Message(format!(
125                    "Current dump format: {}. Use `/dump-format json|raw|trace` to change.",
126                    ctx.debug.dump_format_name()
127                )));
128            }
129
130            match ctx.debug.set_dump_format(args) {
131                Ok(()) => Ok(CommandOutput::Message(format!(
132                    "Debug dump format set to: {args}"
133                ))),
134                Err(e) => Ok(CommandOutput::Message(e.to_string())),
135            }
136        })
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::CommandRegistry;
144    use crate::context::CommandContext;
145    use crate::sink::ChannelSink;
146    use crate::traits::debug::DebugAccess;
147    use crate::traits::messages::MessageAccess;
148    use crate::traits::session::SessionAccess;
149    use std::future::Future;
150    use std::pin::Pin;
151
152    struct MockSession;
153
154    impl SessionAccess for MockSession {
155        fn supports_exit(&self) -> bool {
156            false
157        }
158    }
159
160    struct MockSink;
161
162    impl ChannelSink for MockSink {
163        fn send<'a>(
164            &'a mut self,
165            _msg: &'a str,
166        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
167            Box::pin(async { Ok(()) })
168        }
169
170        fn flush_chunks<'a>(
171            &'a mut self,
172        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
173            Box::pin(async { Ok(()) })
174        }
175
176        fn send_queue_count<'a>(
177            &'a mut self,
178            _count: usize,
179        ) -> Pin<Box<dyn Future<Output = Result<(), CommandError>> + Send + 'a>> {
180            Box::pin(async { Ok(()) })
181        }
182
183        fn supports_exit(&self) -> bool {
184            false
185        }
186    }
187
188    struct MockDebug {
189        dump_active: bool,
190        format: String,
191        enable_result: Result<String, String>,
192        set_format_result: Result<(), String>,
193    }
194
195    impl MockDebug {
196        fn ok() -> Self {
197            Self {
198                dump_active: false,
199                format: "raw".to_owned(),
200                enable_result: Ok("/tmp/dump".to_owned()),
201                set_format_result: Ok(()),
202            }
203        }
204    }
205
206    impl DebugAccess for MockDebug {
207        fn log_status(&self) -> String {
208            "Log file:  <disabled>\n".to_owned()
209        }
210
211        fn read_log_tail<'a>(
212            &'a self,
213            _n: usize,
214        ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
215            Box::pin(async { None })
216        }
217
218        fn scrub(&self, text: &str) -> String {
219            text.to_owned()
220        }
221
222        fn dump_status(&self) -> Option<String> {
223            if self.dump_active {
224                Some("/tmp/dump".to_owned())
225            } else {
226                None
227            }
228        }
229
230        fn dump_format_name(&self) -> String {
231            self.format.clone()
232        }
233
234        fn enable_dump(&mut self, _dir: &str) -> Result<String, CommandError> {
235            self.enable_result.clone().map_err(CommandError::new)
236        }
237
238        fn set_dump_format(&mut self, _name: &str) -> Result<(), CommandError> {
239            self.set_format_result.clone().map_err(CommandError::new)
240        }
241    }
242
243    struct MockMessages;
244
245    impl MessageAccess for MockMessages {
246        fn clear_history(&mut self) {}
247
248        fn queue_len(&self) -> usize {
249            0
250        }
251
252        fn drain_queue(&mut self) -> usize {
253            0
254        }
255
256        fn notify_queue_count<'a>(
257            &'a mut self,
258            _count: usize,
259        ) -> Pin<Box<dyn Future<Output = ()> + Send + 'a>> {
260            Box::pin(async {})
261        }
262    }
263
264    fn make_ctx<'a>(
265        sink: &'a mut MockSink,
266        debug: &'a mut MockDebug,
267        messages: &'a mut MockMessages,
268        session: &'a MockSession,
269        agent: &'a mut crate::NullAgent,
270    ) -> CommandContext<'a> {
271        CommandContext {
272            sink,
273            debug,
274            messages,
275            session: session as &dyn SessionAccess,
276            agent,
277        }
278    }
279
280    #[tokio::test]
281    async fn log_command_formats_status() {
282        let mut sink = MockSink;
283        let mut debug = MockDebug::ok();
284        let mut messages = MockMessages;
285        let session = MockSession;
286        let mut agent = crate::NullAgent;
287        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
288        let out = LogCommand.handle(&mut ctx, "").await.unwrap();
289        let CommandOutput::Message(msg) = out else {
290            panic!("expected Message")
291        };
292        assert!(msg.contains("<disabled>"));
293    }
294
295    #[tokio::test]
296    async fn debug_dump_no_args_reports_inactive() {
297        let mut sink = MockSink;
298        let mut debug = MockDebug::ok();
299        let mut messages = MockMessages;
300        let session = MockSession;
301        let mut agent = crate::NullAgent;
302        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
303        let out = DebugDumpCommand.handle(&mut ctx, "").await.unwrap();
304        let CommandOutput::Message(msg) = out else {
305            panic!("expected Message")
306        };
307        assert!(msg.contains("inactive"));
308    }
309
310    #[tokio::test]
311    async fn debug_dump_with_path_enables_dump() {
312        let mut sink = MockSink;
313        let mut debug = MockDebug::ok();
314        let mut messages = MockMessages;
315        let session = MockSession;
316        let mut agent = crate::NullAgent;
317        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
318        let out = DebugDumpCommand
319            .handle(&mut ctx, "/tmp/dump")
320            .await
321            .unwrap();
322        let CommandOutput::Message(msg) = out else {
323            panic!("expected Message")
324        };
325        assert!(msg.contains("enabled"));
326    }
327
328    #[tokio::test]
329    async fn dump_format_no_args_shows_current() {
330        let mut sink = MockSink;
331        let mut debug = MockDebug::ok();
332        let mut messages = MockMessages;
333        let session = MockSession;
334        let mut agent = crate::NullAgent;
335        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
336        let out = DumpFormatCommand.handle(&mut ctx, "").await.unwrap();
337        let CommandOutput::Message(msg) = out else {
338            panic!("expected Message")
339        };
340        assert!(msg.contains("raw"));
341    }
342
343    #[tokio::test]
344    async fn dump_format_with_arg_switches_format() {
345        let mut sink = MockSink;
346        let mut debug = MockDebug::ok();
347        let mut messages = MockMessages;
348        let session = MockSession;
349        let mut agent = crate::NullAgent;
350        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
351        let out = DumpFormatCommand.handle(&mut ctx, "json").await.unwrap();
352        let CommandOutput::Message(msg) = out else {
353            panic!("expected Message")
354        };
355        assert!(msg.contains("json"));
356    }
357
358    #[test]
359    fn registry_finds_all_debug_commands() {
360        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
361        reg.register(LogCommand);
362        reg.register(DebugDumpCommand);
363        reg.register(DumpFormatCommand);
364
365        assert!(reg.find_handler("/log").is_some());
366        assert!(reg.find_handler("/debug-dump").is_some());
367        assert!(reg.find_handler("/dump-format").is_some());
368    }
369}