zeph_commands/handlers/
debug.rs1use std::future::Future;
7use std::pin::Pin;
8
9use crate::CommandHandler;
10use crate::context::CommandContext;
11use crate::{CommandError, CommandOutput, SlashCategory};
12
13pub 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
55pub 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
115pub 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 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}