返回 CodeWhale
resume.rs
根目录 / crates / tui / src / commands / groups / session / resume.rs
1 //! `/resume` command — portable handler over the session-control facet.
2 //!
3 //! The handler owns route selection and the exact per-route messages/actions;
4 //! filesystem, manager, parser, and picker machinery stay behind the facet's
5 //! `resolve_resume_source` / `import_session_file` / `open_resume_picker`
6 //! delegates (transition blocking is checked before any picker or I/O).
7
8 use super::CommandResult;
9 use codewhale_command_contract::facets::{CommandSessionControlContext, ResumeSource};
10 use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
11 use codewhale_command_contract::metadata::{
12 CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand,
13 };
14
15 pub(in crate::commands) struct ResumeCmd;
16
17 pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo {
18 name: "resume",
19 aliases: &["r"],
20 usage: "/resume [session_id|path/to/export.json]",
21 description_key: "cmd_resume_description",
22 };
23
24 impl ContractRegisterCommand<CommandResult> for ResumeCmd {
25 fn info() -> &'static ContractInfo {
26 &CONTRACT_INFO
27 }
28 fn handler() -> CommandHandler<CommandResult> {
29 CommandHandler::Contextual {
30 capabilities: codewhale_command_contract::handler::CommandCapabilities::SESSION_CONTROL,
31 handler: resume_contextual,
32 }
33 }
34 }
35
36 pub(in crate::commands) fn resume_contextual(
37 contexts: CommandContexts<'_>,
38 arg: Option<&str>,
39 ) -> CommandResult {
40 let mut parts = contexts.into_parts();
41 let Some(control) = parts.control.as_deref_mut() else {
42 return CommandResult::error("Command capability unavailable: session_control".to_string());
43 };
44 resume_portable(control, arg)
45 }
46
47 pub(in crate::commands) fn resume_portable(
48 control: &mut dyn CommandSessionControlContext,
49 arg: Option<&str>,
50 ) -> CommandResult {
51 if control.transition_blocked() {
52 return CommandResult::error(
53 "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first.",
54 );
55 }
56 let Some(raw) = arg.map(str::trim).filter(|s| !s.is_empty()) else {
57 control.open_resume_picker();
58 return CommandResult::ok();
59 };
60 match control.resolve_resume_source(raw) {
61 Ok(ResumeSource::File(path)) => match control.import_session_file(path) {
62 Ok(receipt) => CommandResult::with_message_and_action(
63 format!(
64 "Imported foreign session as {} ({} entries, leaf {})",
65 receipt.truncated_id, receipt.entry_count, receipt.leaf_display
66 ),
67 super::sync_session_action(receipt.sync),
68 ),
69 Err(error) => CommandResult::error(error),
70 },
71 Ok(ResumeSource::Imported(receipt)) => CommandResult::with_message_and_action(
72 format!(
73 "Imported foreign session as {} ({} entries, leaf {})",
74 receipt.truncated_id, receipt.entry_count, receipt.leaf_display
75 ),
76 super::sync_session_action(receipt.sync),
77 ),
78 Ok(ResumeSource::Session {
79 load_path,
80 truncated_id,
81 title,
82 }) => match load_path {
83 Some(path) => CommandResult::action(crate::tui::app::AppAction::LoadSession(path)),
84 None => CommandResult::message(format!("Resuming session {truncated_id} ({title})")),
85 },
86 Ok(ResumeSource::NotFound { raw, error }) => CommandResult::error(format!(
87 "Cannot resume '{raw}': {error}\nUse `/resume` without args to pick, or pass a session id, or a path to an exported session JSON."
88 )),
89 Err(error) => CommandResult::error(error),
90 }
91 }
92
93 #[cfg(test)]
94 mod tests {
95 use super::super::control_test_support::message;
96 use super::*;
97 use codewhale_command_contract::facets::ResumeImportReceipt;
98 use std::path::PathBuf;
99
100 fn control_fake() -> super::super::control_test_support::FakeControl {
101 super::super::control_test_support::FakeControl::default()
102 }
103
104 #[test]
105 fn resume_transition_blocking_wins_before_any_route() {
106 let mut fake = control_fake();
107 fake.blocked = true;
108 let result = resume_portable(&mut fake, Some("anything"));
109 assert!(result.is_error);
110 assert_eq!(
111 message(&result),
112 "Cannot resume while runtime work is active. Wait for the turn to finish, or cancel it first."
113 );
114 assert_eq!(
115 fake.calls.borrow().as_slice(),
116 ["transition_blocked"],
117 "the gate executes exactly once before route work"
118 );
119 }
120
121 #[test]
122 fn resume_bare_opens_the_picker() {
123 let mut fake = control_fake();
124 let result = resume_portable(&mut fake, None);
125 assert!(!result.is_error);
126 assert!(result.action.is_none());
127 assert!(result.message.is_none());
128 assert_eq!(
129 fake.calls.borrow().as_slice(),
130 ["transition_blocked", "open_resume_picker"]
131 );
132 }
133
134 #[test]
135 fn resume_file_and_import_routes_compose_exact_receipts() {
136 let mut fake = control_fake();
137 fake.resume = Some(Ok(ResumeSource::File(PathBuf::from("/tmp/import.json"))));
138 fake.import = Some(Ok(ResumeImportReceipt {
139 truncated_id: "imp-9".to_string(),
140 entry_count: 12,
141 leaf_display: "leaf-3".to_string(),
142 sync: super::super::lifecycle_test_support::sync_payload("imp-9"),
143 }));
144 let result = resume_portable(&mut fake, Some("/tmp/import.json"));
145 assert!(!result.is_error);
146 assert_eq!(
147 message(&result),
148 "Imported foreign session as imp-9 (12 entries, leaf leaf-3)"
149 );
150 // The engine must adopt the imported conversation; a message-only
151 // result would leave it on the previous session.
152 assert!(
153 matches!(
154 result.action,
155 Some(crate::tui::app::AppAction::SyncSession { ref session_id, .. })
156 if session_id.as_deref() == Some("imp-9")
157 ),
158 "{result:?}"
159 );
160
161 let mut fake = control_fake();
162 fake.resume = Some(Ok(ResumeSource::File(PathBuf::from("/tmp/x.json"))));
163 fake.import = Some(Err(
164 "File x.json is not a recognized session export".to_string()
165 ));
166 let result = resume_portable(&mut fake, Some("/tmp/x.json"));
167 assert!(result.is_error);
168 assert_eq!(
169 message(&result),
170 "File x.json is not a recognized session export"
171 );
172
173 let mut fake = control_fake();
174 fake.resume = Some(Ok(ResumeSource::Imported(ResumeImportReceipt {
175 truncated_id: "c-1".to_string(),
176 entry_count: 0,
177 leaf_display: "(none)".to_string(),
178 sync: super::super::lifecycle_test_support::sync_payload("c-1"),
179 })));
180 let result = resume_portable(&mut fake, Some("inline-json"));
181 assert_eq!(
182 message(&result),
183 "Imported foreign session as c-1 (0 entries, leaf (none))"
184 );
185 assert!(
186 matches!(
187 result.action,
188 Some(crate::tui::app::AppAction::SyncSession { ref session_id, .. })
189 if session_id.as_deref() == Some("c-1")
190 ),
191 "{result:?}"
192 );
193 }
194
195 #[test]
196 fn resume_session_and_not_found_routes_are_exact() {
197 let mut fake = control_fake();
198 fake.resume = Some(Ok(ResumeSource::Session {
199 load_path: Some(PathBuf::from("/tmp/sessions/abc123.json")),
200 truncated_id: "abc123".to_string(),
201 title: "Control Session".to_string(),
202 }));
203 let result = resume_portable(&mut fake, Some("abc123"));
204 assert!(!result.is_error);
205 assert!(matches!(
206 result.action,
207 Some(crate::tui::app::AppAction::LoadSession(path)) if path == *"/tmp/sessions/abc123.json"
208 ));
209
210 let mut fake = control_fake();
211 fake.resume = Some(Ok(ResumeSource::Session {
212 load_path: None,
213 truncated_id: "abc123".to_string(),
214 title: "Control Session".to_string(),
215 }));
216 let result = resume_portable(&mut fake, Some("abc123"));
217 assert_eq!(
218 message(&result),
219 "Resuming session abc123 (Control Session)"
220 );
221
222 let mut fake = control_fake();
223 fake.resume = Some(Ok(ResumeSource::NotFound {
224 raw: "nope".to_string(),
225 error: "no such session".to_string(),
226 }));
227 let result = resume_portable(&mut fake, Some("nope"));
228 assert!(result.is_error);
229 assert_eq!(
230 message(&result),
231 "Cannot resume 'nope': no such session\nUse `/resume` without args to pick, or pass a session id, or a path to an exported session JSON."
232 );
233 }
234
235 #[test]
236 fn resume_host_lookup_errors_pass_through() {
237 let mut fake = control_fake();
238 fake.resume = Some(Err("could not open sessions directory: boom".to_string()));
239 let result = resume_portable(&mut fake, Some("abc"));
240 assert!(result.is_error);
241 assert_eq!(message(&result), "could not open sessions directory: boom");
242 }
243
244 #[test]
245 fn resume_missing_control_authority_fails_safely() {
246 let contexts = codewhale_command_contract::handler::CommandContexts::empty();
247 let result = resume_contextual(contexts, None);
248 assert!(result.is_error);
249 assert_eq!(
250 message(&result),
251 "Command capability unavailable: session_control"
252 );
253 }
254 }
255
255 lines RUST