zeph-commands 0.21.2

Slash command registry, handler trait, and channel sink abstraction for Zeph
Documentation
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Debug command handlers: `/log`, `/debug-dump`, `/dump-format`.

use std::future::Future;
use std::pin::Pin;

use crate::CommandHandler;
use crate::context::CommandContext;
use crate::{CommandError, CommandOutput, SlashCategory};

/// Show log file path and recent log entries.
pub struct LogCommand;

impl CommandHandler<CommandContext<'_>> for LogCommand {
    fn name(&self) -> &'static str {
        "/log"
    }

    fn description(&self) -> &'static str {
        "Show log tail and current log file path"
    }

    fn category(&self) -> SlashCategory {
        SlashCategory::Debugging
    }

    fn handle<'a>(
        &'a self,
        ctx: &'a mut CommandContext<'_>,
        _args: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
        use tracing::Instrument as _;
        let span = tracing::info_span!("commands.log.handle");
        Box::pin(
            async move {
                let mut out = ctx.debug.log_status();
                if let Some(tail) = ctx.debug.read_log_tail(20).await {
                    out.push('\n');
                    out.push_str("Recent entries:\n");
                    out.push_str(&ctx.debug.scrub(&tail));
                }
                Ok(CommandOutput::Message(out.trim_end().to_owned()))
            }
            .instrument(span),
        )
    }
}

/// Enable or show the status of debug dump output.
///
/// With no arguments, reports whether debug dump is active and where.
/// With a path argument, enables debug dump to that directory.
pub struct DebugDumpCommand;

impl CommandHandler<CommandContext<'_>> for DebugDumpCommand {
    fn name(&self) -> &'static str {
        "/debug-dump"
    }

    fn description(&self) -> &'static str {
        "Enable or toggle debug dump output"
    }

    fn args_hint(&self) -> &'static str {
        "[path]"
    }

    fn category(&self) -> SlashCategory {
        SlashCategory::Debugging
    }

    fn handle<'a>(
        &'a self,
        ctx: &'a mut CommandContext<'_>,
        args: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
        use tracing::Instrument as _;
        let span = tracing::info_span!("commands.debug_dump.handle");
        Box::pin(
            async move {
                if args.is_empty() {
                    let msg = match ctx.debug.dump_status() {
                        Some(path) => format!("Debug dump active: {path}"),
                        None => "Debug dump is inactive. Use `/debug-dump <path>` to enable, \
                         or start with `--debug-dump [dir]`."
                            .to_owned(),
                    };
                    return Ok(CommandOutput::Message(msg));
                }

                match ctx.debug.enable_dump(args) {
                    Ok(path) => Ok(CommandOutput::Message(format!(
                        "Debug dump enabled: {path}"
                    ))),
                    Err(e) => Ok(CommandOutput::Message(format!(
                        "Failed to enable debug dump: {e}"
                    ))),
                }
            }
            .instrument(span),
        )
    }
}

/// Switch debug dump format at runtime.
pub struct DumpFormatCommand;

impl CommandHandler<CommandContext<'_>> for DumpFormatCommand {
    fn name(&self) -> &'static str {
        "/dump-format"
    }

    fn description(&self) -> &'static str {
        "Switch debug dump format at runtime"
    }

    fn args_hint(&self) -> &'static str {
        "<json|raw|trace>"
    }

    fn category(&self) -> SlashCategory {
        SlashCategory::Debugging
    }

    fn handle<'a>(
        &'a self,
        ctx: &'a mut CommandContext<'_>,
        args: &'a str,
    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
        use tracing::Instrument as _;
        let span = tracing::info_span!("commands.dump_format.handle");
        Box::pin(
            async move {
                if args.is_empty() {
                    return Ok(CommandOutput::Message(format!(
                        "Current dump format: {}. Use `/dump-format json|raw|trace` to change.",
                        ctx.debug.dump_format_name()
                    )));
                }

                match ctx.debug.set_dump_format(args) {
                    Ok(()) => Ok(CommandOutput::Message(format!(
                        "Debug dump format set to: {args}"
                    ))),
                    Err(e) => Ok(CommandOutput::Message(e.to_string())),
                }
            }
            .instrument(span),
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::CommandRegistry;
    use crate::context::CommandContext;
    use crate::handlers::test_helpers::{MockMessages, MockSession};
    use crate::sink::NullSink;
    use crate::traits::debug::DebugAccess;
    use crate::traits::session::SessionAccess;
    use std::future::Future;
    use std::pin::Pin;

    fn make_ctx<'a>(
        sink: &'a mut NullSink,
        debug: &'a mut MockDebug,
        messages: &'a mut MockMessages,
        session: &'a MockSession,
        agent: &'a mut crate::NullAgent,
    ) -> crate::context::CommandContext<'a> {
        crate::context::CommandContext {
            sink,
            debug,
            messages,
            session: session as &dyn SessionAccess,
            agent,
        }
    }

    // Stateful mock required to assert dump enable/format behaviour.
    struct MockDebug {
        dump_active: bool,
        format: String,
        enable_result: Result<String, String>,
        set_format_result: Result<(), String>,
    }

    impl MockDebug {
        fn ok() -> Self {
            Self {
                dump_active: false,
                format: "raw".to_owned(),
                enable_result: Ok("/tmp/dump".to_owned()),
                set_format_result: Ok(()),
            }
        }
    }

    impl DebugAccess for MockDebug {
        fn log_status(&self) -> String {
            "Log file:  <disabled>\n".to_owned()
        }

        fn read_log_tail<'a>(
            &'a self,
            _n: usize,
        ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + 'a>> {
            Box::pin(async { None })
        }

        fn scrub(&self, text: &str) -> String {
            text.to_owned()
        }

        fn dump_status(&self) -> Option<String> {
            if self.dump_active {
                Some("/tmp/dump".to_owned())
            } else {
                None
            }
        }

        fn dump_format_name(&self) -> String {
            self.format.clone()
        }

        fn enable_dump(&mut self, _dir: &str) -> Result<String, CommandError> {
            self.enable_result.clone().map_err(CommandError::new)
        }

        fn set_dump_format(&mut self, _name: &str) -> Result<(), CommandError> {
            self.set_format_result.clone().map_err(CommandError::new)
        }
    }

    #[tokio::test]
    async fn log_command_formats_status() {
        let mut sink = NullSink;
        let mut debug = MockDebug::ok();
        let mut messages = MockMessages;
        let session = MockSession;
        let mut agent = crate::NullAgent;
        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
        let out = LogCommand.handle(&mut ctx, "").await.unwrap();
        let CommandOutput::Message(msg) = out else {
            panic!("expected Message")
        };
        assert!(msg.contains("<disabled>"));
    }

    #[tokio::test]
    async fn debug_dump_no_args_reports_inactive() {
        let mut sink = NullSink;
        let mut debug = MockDebug::ok();
        let mut messages = MockMessages;
        let session = MockSession;
        let mut agent = crate::NullAgent;
        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
        let out = DebugDumpCommand.handle(&mut ctx, "").await.unwrap();
        let CommandOutput::Message(msg) = out else {
            panic!("expected Message")
        };
        assert!(msg.contains("inactive"));
    }

    #[tokio::test]
    async fn debug_dump_with_path_enables_dump() {
        let mut sink = NullSink;
        let mut debug = MockDebug::ok();
        let mut messages = MockMessages;
        let session = MockSession;
        let mut agent = crate::NullAgent;
        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
        let out = DebugDumpCommand
            .handle(&mut ctx, "/tmp/dump")
            .await
            .unwrap();
        let CommandOutput::Message(msg) = out else {
            panic!("expected Message")
        };
        assert!(msg.contains("enabled"));
    }

    #[tokio::test]
    async fn dump_format_no_args_shows_current() {
        let mut sink = NullSink;
        let mut debug = MockDebug::ok();
        let mut messages = MockMessages;
        let session = MockSession;
        let mut agent = crate::NullAgent;
        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
        let out = DumpFormatCommand.handle(&mut ctx, "").await.unwrap();
        let CommandOutput::Message(msg) = out else {
            panic!("expected Message")
        };
        assert!(msg.contains("raw"));
    }

    #[tokio::test]
    async fn dump_format_with_arg_switches_format() {
        let mut sink = NullSink;
        let mut debug = MockDebug::ok();
        let mut messages = MockMessages;
        let session = MockSession;
        let mut agent = crate::NullAgent;
        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
        let out = DumpFormatCommand.handle(&mut ctx, "json").await.unwrap();
        let CommandOutput::Message(msg) = out else {
            panic!("expected Message")
        };
        assert!(msg.contains("json"));
    }

    #[test]
    fn registry_finds_all_debug_commands() {
        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
        reg.register(LogCommand);
        reg.register(DebugDumpCommand);
        reg.register(DumpFormatCommand);

        assert!(reg.find_handler("/log").is_some());
        assert!(reg.find_handler("/debug-dump").is_some());
        assert!(reg.find_handler("/dump-format").is_some());
    }
}