Skip to main content

zeph_commands/handlers/
checkpoint.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! `/undo` and `/redo` slash command handlers.
5
6use std::future::Future;
7use std::pin::Pin;
8
9use crate::context::CommandContext;
10use crate::{CommandError, CommandHandler, CommandOutput, SlashCategory};
11
12/// Undo the last N file-mutating shell commands executed in this session.
13///
14/// Syntax: `/undo [N]` — omit N to undo one step, or pass `list` to show the undo stack.
15pub struct UndoCommand;
16
17impl CommandHandler<CommandContext<'_>> for UndoCommand {
18    fn name(&self) -> &'static str {
19        "/undo"
20    }
21
22    fn description(&self) -> &'static str {
23        "Undo the last N file-mutating shell commands (session-scoped)"
24    }
25
26    fn args_hint(&self) -> &'static str {
27        "[N | list]"
28    }
29
30    fn category(&self) -> SlashCategory {
31        SlashCategory::Session
32    }
33
34    fn requires_auth(&self) -> bool {
35        true
36    }
37
38    fn handle<'a>(
39        &'a self,
40        ctx: &'a mut CommandContext<'_>,
41        args: &'a str,
42    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
43        use tracing::Instrument as _;
44        let span = tracing::info_span!("commands.undo.handle");
45        Box::pin(
46            async move {
47                let result = ctx.agent.handle_undo(args).await?;
48                Ok(CommandOutput::Message(result))
49            }
50            .instrument(span),
51        )
52    }
53}
54
55/// Re-apply the last undone shell command.
56///
57/// Syntax: `/redo` — re-applies the most recently undone command.
58pub struct RedoCommand;
59
60impl CommandHandler<CommandContext<'_>> for RedoCommand {
61    fn name(&self) -> &'static str {
62        "/redo"
63    }
64
65    fn description(&self) -> &'static str {
66        "Re-apply the last undone shell command"
67    }
68
69    fn args_hint(&self) -> &'static str {
70        ""
71    }
72
73    fn category(&self) -> SlashCategory {
74        SlashCategory::Session
75    }
76
77    fn requires_auth(&self) -> bool {
78        true
79    }
80
81    fn handle<'a>(
82        &'a self,
83        ctx: &'a mut CommandContext<'_>,
84        args: &'a str,
85    ) -> Pin<Box<dyn Future<Output = Result<CommandOutput, CommandError>> + Send + 'a>> {
86        use tracing::Instrument as _;
87        let span = tracing::info_span!("commands.redo.handle");
88        Box::pin(
89            async move {
90                let result = ctx.agent.handle_redo(args).await?;
91                Ok(CommandOutput::Message(result))
92            }
93            .instrument(span),
94        )
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::CommandRegistry;
102    use crate::handlers::test_helpers::{MockDebug, MockMessages, MockSession, make_ctx};
103    use crate::sink::NullSink;
104
105    #[test]
106    fn undo_name_and_description() {
107        assert_eq!(UndoCommand.name(), "/undo");
108        assert!(!UndoCommand.description().is_empty());
109    }
110
111    #[test]
112    fn redo_name_and_description() {
113        assert_eq!(RedoCommand.name(), "/redo");
114        assert!(!RedoCommand.description().is_empty());
115    }
116
117    #[tokio::test]
118    async fn undo_not_supported_returns_ok_message() {
119        let mut sink = NullSink;
120        let mut debug = MockDebug;
121        let mut messages = MockMessages;
122        let session = MockSession;
123        let mut agent = crate::NullAgent;
124        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
125        let result = UndoCommand.handle(&mut ctx, "").await;
126        assert!(result.is_ok());
127        if let Ok(CommandOutput::Message(msg)) = result {
128            assert!(!msg.is_empty());
129        }
130    }
131
132    #[tokio::test]
133    async fn redo_not_supported_returns_ok_message() {
134        let mut sink = NullSink;
135        let mut debug = MockDebug;
136        let mut messages = MockMessages;
137        let session = MockSession;
138        let mut agent = crate::NullAgent;
139        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
140        let result = RedoCommand.handle(&mut ctx, "").await;
141        assert!(result.is_ok());
142        if let Ok(CommandOutput::Message(msg)) = result {
143            assert!(!msg.is_empty());
144        }
145    }
146
147    #[tokio::test]
148    async fn undo_dispatch_rejected_when_untrusted() {
149        let mut sink = NullSink;
150        let mut debug = MockDebug;
151        let mut messages = MockMessages;
152        let session = MockSession;
153        let mut agent = crate::NullAgent;
154        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
155
156        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
157        reg.register(UndoCommand);
158
159        let result = reg.dispatch(&mut ctx, "/undo", false).await;
160        let err = result.unwrap().unwrap_err();
161        assert!(err.0.contains("trusted"));
162    }
163
164    #[tokio::test]
165    async fn undo_dispatch_allowed_when_trusted() {
166        let mut sink = NullSink;
167        let mut debug = MockDebug;
168        let mut messages = MockMessages;
169        let session = MockSession;
170        let mut agent = crate::NullAgent;
171        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
172
173        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
174        reg.register(UndoCommand);
175
176        let result = reg.dispatch(&mut ctx, "/undo", true).await;
177        assert!(result.unwrap().is_ok());
178    }
179
180    #[tokio::test]
181    async fn redo_dispatch_rejected_when_untrusted() {
182        let mut sink = NullSink;
183        let mut debug = MockDebug;
184        let mut messages = MockMessages;
185        let session = MockSession;
186        let mut agent = crate::NullAgent;
187        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
188
189        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
190        reg.register(RedoCommand);
191
192        let result = reg.dispatch(&mut ctx, "/redo", false).await;
193        let err = result.unwrap().unwrap_err();
194        assert!(err.0.contains("trusted"));
195    }
196
197    #[tokio::test]
198    async fn redo_dispatch_allowed_when_trusted() {
199        let mut sink = NullSink;
200        let mut debug = MockDebug;
201        let mut messages = MockMessages;
202        let session = MockSession;
203        let mut agent = crate::NullAgent;
204        let mut ctx = make_ctx(&mut sink, &mut debug, &mut messages, &session, &mut agent);
205
206        let mut reg: CommandRegistry<CommandContext<'_>> = CommandRegistry::new();
207        reg.register(RedoCommand);
208
209        let result = reg.dispatch(&mut ctx, "/redo", true).await;
210        assert!(result.unwrap().is_ok());
211    }
212}