返回 CodeWhale
restore.rs
根目录 / crates / tui / src / commands / groups / skills / restore.rs
1 //! `/restore` slash command — roll back the workspace to a prior snapshot.
2 //!
3 //! `/restore` (no arg) lists the 20 most recent snapshots so the user can
4 //! see what's available. `/restore list [N]` lists more snapshots, capped
5 //! at 100. `/restore <N>` restores the *N*th-most-recent snapshot, where
6 //! `N=1` is the newest. Without trusted/full access we refuse to mutate files unless
7 //! the user has explicitly trusted the workspace (`/trust on` or Full Access) —
8 //! the user can always view the list, just not one-shot revert without a
9 //! safety net.
10 //!
11 //! FEAT-022 Phase 4: portable contextual dispatch. `SnapshotRepo` and the
12 //! approval state stay host-side (`CommandSkillGroupContext` delegates); the
13 //! portable handler owns all parsing, formatting, and the trust gate.
14
15 use chrono::TimeZone;
16
17 use codewhale_command_contract::facets::{CommandSkillGroupContext, SnapshotEntry};
18 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
19 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
20
21 use crate::commands::CommandResult;
22
23 const DEFAULT_LIST_LIMIT: usize = 20;
24 const MAX_LIST_LIMIT: usize = 100;
25 const MAX_RESTORE_INDEX: usize = 1000;
26
27 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
28 name: "restore",
29 aliases: &[],
30 usage: "/restore [N|list [N]]",
31 description_key: "cmd_restore_description",
32 };
33
34 pub(in crate::commands) struct RestoreCmd;
35
36 impl RegisterCommand<CommandResult> for RestoreCmd {
37 fn info() -> &'static CommandInfo {
38 &COMMAND_INFO
39 }
40
41 fn handler() -> CommandHandler<CommandResult> {
42 CommandHandler::Contextual {
43 capabilities: codewhale_command_contract::handler::CommandCapabilities::SKILL_GROUP,
44 handler: restore_contextual,
45 }
46 }
47 }
48
49 /// Contextual `/restore` dispatch (FEAT-022 D4): exactly the skill-group facet
50 /// (snapshot list/restore + approval state — no `MODE_POLICY` declaration).
51 fn restore_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
52 let mut parts = contexts.into_parts();
53 let Some(skill_group) = parts.skill_group.as_deref_mut() else {
54 return CommandResult::error("Command capability unavailable: skill_group");
55 };
56 restore(skill_group, arg)
57 }
58
59 /// Portable `/restore` dispatch — byte-identical to the baseline handler.
60 ///
61 /// The host owns `SnapshotRepo` open/list/restore and the yolo/trust posture;
62 /// the handler composes every message, error, listing, and the trust gate.
63 fn restore(group: &mut dyn CommandSkillGroupContext, arg: Option<&str>) -> CommandResult {
64 let Some(arg) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
65 let snapshots = match group.snapshot_list(DEFAULT_LIST_LIMIT) {
66 Ok(s) => s,
67 Err(err) => return CommandResult::error(err),
68 };
69 if snapshots.is_empty() {
70 return no_snapshots_message();
71 }
72 return CommandResult::message(format_listing(&snapshots));
73 };
74
75 if let Some(limit) = match parse_list_arg(arg) {
76 Ok(limit) => limit,
77 Err(message) => return CommandResult::error(message),
78 } {
79 let snapshots = match group.snapshot_list(limit) {
80 Ok(s) => s,
81 Err(err) => return CommandResult::error(err),
82 };
83 if snapshots.is_empty() {
84 return no_snapshots_message();
85 }
86 return CommandResult::message(format_listing(&snapshots));
87 }
88
89 let n: usize = match arg.parse() {
90 Ok(n) if (1..=MAX_RESTORE_INDEX).contains(&n) => n,
91 Ok(n) if n > MAX_RESTORE_INDEX => {
92 return CommandResult::error(format!(
93 "Restore index must be <= {MAX_RESTORE_INDEX}; got {n}. Use /restore list [N] to inspect snapshots first.",
94 ));
95 }
96 _ => {
97 return CommandResult::error(format!(
98 "Usage: /restore <N> or /restore list [N] (N is 1-based; got '{arg}')",
99 ));
100 }
101 };
102 let snapshots = match group.snapshot_list(n.max(DEFAULT_LIST_LIMIT)) {
103 Ok(s) => s,
104 Err(err) => return CommandResult::error(err),
105 };
106 if snapshots.is_empty() {
107 return no_snapshots_message();
108 }
109
110 if n > snapshots.len() {
111 return CommandResult::error(format!(
112 "Only {} snapshot(s) available; asked for #{n}.",
113 snapshots.len(),
114 ));
115 }
116
117 // Sessions without trusted/full access get a confirmation gate. We don't have a true
118 // modal-confirmation path inside slash commands today, so the gate
119 // is "require trust mode" — `/trust on` or Full Access. Users in plain
120 // Agent mode get a clear message explaining how to proceed.
121 let approval = group.approval_state();
122 if !(approval.yolo || approval.trust_mode) {
123 return CommandResult::message(format!(
124 "Refusing to restore snapshot #{n} ('{}') outside trusted mode.\n\
125 Run `/trust on` or select Full Access with Shift+Tab, then re-run `/restore {n}`.",
126 snapshots[n - 1].label,
127 ));
128 }
129
130 let target = &snapshots[n - 1];
131 if let Err(err) = group.restore_snapshot(&target.id) {
132 return CommandResult::error(err);
133 }
134
135 CommandResult::message(format!(
136 "Restored snapshot #{n} ('{}', {}). Workspace files have been reverted; conversation history is unchanged.",
137 target.label,
138 short_sha(target.id.as_str()),
139 ))
140 }
141
142 fn parse_list_arg(arg: &str) -> Result<Option<usize>, String> {
143 let mut parts = arg.split_whitespace();
144 let action = match parts.next() {
145 Some(action) => action,
146 None => return Ok(None),
147 };
148 if action != "list" {
149 return Ok(None);
150 }
151 let Some(value) = parts.next() else {
152 return Ok(Some(DEFAULT_LIST_LIMIT));
153 };
154 if parts.next().is_some() {
155 return Err(format!(
156 "Usage: /restore list [N] (got extra arguments in '{arg}')",
157 ));
158 }
159 match value.parse::<usize>() {
160 Ok(limit @ 1..=MAX_LIST_LIMIT) => Ok(Some(limit)),
161 Ok(limit) if limit > MAX_LIST_LIMIT => Err(format!(
162 "Restore list limit must be <= {MAX_LIST_LIMIT}; got {limit}.",
163 )),
164 _ => Err(format!(
165 "Usage: /restore list [N] (N must be >= 1; got '{value}')",
166 )),
167 }
168 }
169
170 fn no_snapshots_message() -> CommandResult {
171 CommandResult::message(
172 "No snapshots yet. Send a message to create the first pre-turn snapshot.",
173 )
174 }
175
176 fn format_listing(snapshots: &[SnapshotEntry]) -> String {
177 let mut out = String::from(
178 "Recent snapshots (newest first; pass /restore <N> to revert; /restore list 50 shows more):\n",
179 );
180 for (i, s) in snapshots.iter().enumerate() {
181 out.push_str(&format!(
182 " #{:<2} {} {} {}\n",
183 i + 1,
184 format_snapshot_time(s.timestamp),
185 short_sha(s.id.as_str()),
186 s.label,
187 ));
188 }
189 out
190 }
191
192 fn format_snapshot_time(timestamp: i64) -> String {
193 match chrono::Utc.timestamp_opt(timestamp, 0).single() {
194 Some(dt) => dt.format("%Y-%m-%d %H:%M UTC").to_string(),
195 None => "unknown time".to_string(),
196 }
197 }
198
199 fn short_sha(sha: &str) -> &str {
200 &sha[..sha.len().min(8)]
201 }
202
203 #[cfg(test)]
204 mod tests {
205 use super::*;
206 use codewhale_command_contract::facets::{
207 CommandApprovalState, RemoteRegistryOutcome, ReviewOutcome, SkillActivationError,
208 SkillMutationReceipt, SkillRecommendation, SkillSyncOutcome, SkillTargetScope,
209 };
210
211 struct FakeSkillGroup {
212 snapshots: Result<Vec<SnapshotEntry>, String>,
213 restore: Result<(), String>,
214 approval: CommandApprovalState,
215 }
216 impl FakeSkillGroup {
217 fn new(snapshots: Vec<SnapshotEntry>) -> Self {
218 Self {
219 snapshots: Ok(snapshots),
220 restore: Ok(()),
221 approval: CommandApprovalState {
222 yolo: true,
223 trust_mode: false,
224 },
225 }
226 }
227 }
228 impl CommandSkillGroupContext for FakeSkillGroup {
229 fn skill_registry_projection(
230 &self,
231 ) -> codewhale_command_contract::facets::SkillRegistryProjection {
232 unimplemented!("not used by restore tests")
233 }
234 fn activate_skill(
235 &mut self,
236 _name: &str,
237 ) -> Result<codewhale_command_contract::facets::SkillActivationOutcome, SkillActivationError>
238 {
239 unimplemented!("not used by restore tests")
240 }
241 fn install_skill(
242 &mut self,
243 _scope: Option<SkillTargetScope>,
244 _spec: &str,
245 ) -> Result<SkillMutationReceipt, String> {
246 unimplemented!("not used by restore tests")
247 }
248 fn update_skill(
249 &mut self,
250 _scope: Option<SkillTargetScope>,
251 _name: &str,
252 ) -> Result<SkillMutationReceipt, String> {
253 unimplemented!("not used by restore tests")
254 }
255 fn uninstall_skill(
256 &mut self,
257 _scope: Option<SkillTargetScope>,
258 _name: &str,
259 ) -> Result<SkillMutationReceipt, String> {
260 unimplemented!("not used by restore tests")
261 }
262 fn trust_skill(
263 &mut self,
264 _scope: Option<SkillTargetScope>,
265 _name: &str,
266 ) -> Result<SkillMutationReceipt, String> {
267 unimplemented!("not used by restore tests")
268 }
269 fn fetch_remote_registry(&mut self) -> Result<RemoteRegistryOutcome, String> {
270 unimplemented!("not used by restore tests")
271 }
272 fn recommend_skills(&mut self, _task: &str) -> Result<Vec<SkillRecommendation>, String> {
273 unimplemented!("not used by restore tests")
274 }
275 fn sync_registry(&mut self) -> Result<SkillSyncOutcome, String> {
276 unimplemented!("not used by restore tests")
277 }
278 fn run_review(&mut self) -> Result<ReviewOutcome, String> {
279 unimplemented!("not used by restore tests")
280 }
281 fn snapshot_list(&mut self, limit: usize) -> Result<Vec<SnapshotEntry>, String> {
282 match &self.snapshots {
283 Ok(snapshots) => Ok(snapshots.iter().take(limit).cloned().collect()),
284 Err(err) => Err(err.clone()),
285 }
286 }
287 fn restore_snapshot(&mut self, _id: &str) -> Result<(), String> {
288 self.restore.clone()
289 }
290 fn approval_state(&self) -> CommandApprovalState {
291 self.approval
292 }
293 }
294
295 fn snap(label: &str, id: &str, timestamp: i64) -> SnapshotEntry {
296 SnapshotEntry {
297 id: id.to_string(),
298 label: label.to_string(),
299 timestamp,
300 }
301 }
302
303 #[test]
304 fn restore_with_no_snapshots_shows_empty_message() {
305 let mut group = FakeSkillGroup::new(vec![]);
306 let result = restore(&mut group, None);
307 let msg = result.message.expect("expected message");
308 assert!(msg.contains("No snapshots"));
309 }
310
311 #[test]
312 fn restore_lists_when_no_arg_provided() {
313 let mut group = FakeSkillGroup::new(vec![
314 snap("post-turn:1", "11111111", 1_700_000_000),
315 snap("pre-turn:1", "22222222", 1_699_000_000),
316 ]);
317 let result = restore(&mut group, None);
318 let msg = result.message.expect("expected message");
319 assert!(msg.contains("post-turn:1"));
320 assert!(msg.contains("pre-turn:1"));
321 assert!(msg.contains("#1"));
322 assert!(msg.contains("#2"));
323 assert!(msg.contains("2023-11-14 22:13 UTC"), "{msg}");
324 }
325
326 #[test]
327 fn restore_list_subcommand_accepts_explicit_limit() {
328 let mut group = FakeSkillGroup::new(vec![
329 snap("turn:1", "11111111", 1_700_000_000),
330 snap("turn:2", "22222222", 1_699_000_000),
331 snap("turn:3", "33333333", 1_698_000_000),
332 ]);
333 let result = restore(&mut group, Some("list 2"));
334 let msg = result.message.expect("expected message");
335 assert!(msg.contains("#2"), "{msg}");
336 assert!(!msg.contains("#3"), "{msg}");
337 }
338
339 #[test]
340 fn restore_list_subcommand_rejects_invalid_limit() {
341 let mut group = FakeSkillGroup::new(vec![]);
342 let result = restore(&mut group, Some("list nope"));
343 assert!(result.is_error);
344 assert!(result.message.unwrap().contains("Usage: /restore list [N]"));
345 }
346
347 #[test]
348 fn restore_list_subcommand_rejects_limit_above_cap() {
349 let mut group = FakeSkillGroup::new(vec![]);
350 let result = restore(&mut group, Some("list 101"));
351 assert!(result.is_error);
352 assert!(
353 result
354 .message
355 .unwrap()
356 .contains("Restore list limit must be <= 100")
357 );
358 }
359
360 #[test]
361 fn restore_numeric_index_rejects_unbounded_query() {
362 let mut group = FakeSkillGroup::new(vec![]);
363 let result = restore(&mut group, Some("1001"));
364 assert!(result.is_error);
365 assert!(
366 result
367 .message
368 .unwrap()
369 .contains("Restore index must be <= 1000")
370 );
371 }
372
373 #[test]
374 fn restore_in_yolo_reverts_workspace() {
375 let mut group = FakeSkillGroup::new(vec![
376 snap("post-turn:1", "22222222", 1_700_000_000),
377 snap("pre-turn:1", "11111111", 1_699_000_000),
378 ]);
379 let result = restore(&mut group, Some("2"));
380 assert!(!result.is_error);
381 assert!(result.message.unwrap().contains("Restored snapshot #2"));
382 }
383
384 #[test]
385 fn restore_outside_trust_mode_refuses() {
386 let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
387 group.approval = CommandApprovalState {
388 yolo: false,
389 trust_mode: false,
390 };
391 let result = restore(&mut group, Some("1"));
392 let msg = result.message.expect("expected message");
393 assert!(msg.contains("Refusing"));
394 assert!(msg.contains("/trust on"));
395 }
396
397 #[test]
398 fn restore_invalid_index_returns_error() {
399 let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
400 let result = restore(&mut group, Some("99"));
401 let msg = result.message.expect("expected message");
402 assert!(msg.contains("Only 1 snapshot"));
403 }
404
405 #[test]
406 fn restore_zero_index_returns_error() {
407 let mut group = FakeSkillGroup::new(vec![snap("pre-turn:1", "11111111", 1_700_000_000)]);
408 let result = restore(&mut group, Some("0"));
409 assert!(result.is_error);
410 assert!(result.message.unwrap().contains("Usage:"));
411 }
412
413 #[test]
414 fn restore_host_error_reaches_boundary() {
415 let mut group = FakeSkillGroup::new(vec![]);
416 group.snapshots = Err("Snapshot repo unavailable for /ws: boom".to_string());
417 let result = restore(&mut group, None);
418 assert!(result.is_error);
419 assert_eq!(
420 result.message.unwrap(),
421 "Error: Snapshot repo unavailable for /ws: boom"
422 );
423 }
424
425 #[test]
426 fn restore_missing_facet_errors_are_safe() {
427 let result = restore_contextual(CommandContexts::empty(), Some("1"));
428 assert!(result.is_error);
429 assert_eq!(
430 result.message.unwrap(),
431 "Error: Command capability unavailable: skill_group"
432 );
433 }
434 }
435
435 lines RUST