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