返回 CodeWhale
jobs.rs
根目录 / crates / tui / src / commands / groups / utility / jobs.rs
1 //! Shell job-center commands.
2
3 use codewhale_command_contract::handler::CommandHandler;
4 use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
5
6 use crate::commands::CommandResult;
7 use crate::tui::app::{AppAction, ShellJobAction};
8
9 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
10 name: "jobs",
11 aliases: &["job", "zuoye"],
12 usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id|all>]",
13 description_key: "cmd_jobs_description",
14 };
15
16 pub(in crate::commands) struct JobsCmd;
17
18 impl RegisterCommand<CommandResult> for JobsCmd {
19 fn info() -> &'static CommandInfo {
20 &COMMAND_INFO
21 }
22
23 fn handler() -> CommandHandler<CommandResult> {
24 CommandHandler::Pure(jobs)
25 }
26 }
27
28 fn is_cancel_all_token(id: &str) -> bool {
29 id.eq_ignore_ascii_case("all")
30 }
31
32 fn jobs(args: Option<&str>) -> CommandResult {
33 let raw = args.unwrap_or("").trim();
34 if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
35 return CommandResult::action(AppAction::ShellJob(ShellJobAction::List));
36 }
37
38 let mut parts = raw.splitn(3, char::is_whitespace);
39 let action = parts.next().unwrap_or("").to_ascii_lowercase();
40 let id = parts.next().map(str::trim).filter(|s| !s.is_empty());
41 let rest = parts.next().map(str::trim).unwrap_or("");
42
43 match action.as_str() {
44 "list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
45 "show" | "inspect" => match id {
46 Some(id) if id.starts_with("cloud_") => show_cloud_job(id),
47 Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
48 id: id.to_string(),
49 })),
50 None => CommandResult::error("Usage: /jobs show <id>"),
51 },
52 "poll" | "wait" => match id {
53 Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Poll {
54 id: id.to_string(),
55 wait: action == "wait",
56 })),
57 None => CommandResult::error("Usage: /jobs poll <id>"),
58 },
59 "stdin" | "send" => match id {
60 Some(id) if !rest.is_empty() => {
61 CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
62 id: id.to_string(),
63 input: rest.to_string(),
64 close: false,
65 }))
66 }
67 _ => CommandResult::error("Usage: /jobs stdin <id> <input>"),
68 },
69 "close-stdin" | "eof" => match id {
70 Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::SendStdin {
71 id: id.to_string(),
72 input: String::new(),
73 close: true,
74 })),
75 None => CommandResult::error("Usage: /jobs close-stdin <id>"),
76 },
77 "cancel" | "kill" | "stop" => match id {
78 Some(id) if id.starts_with("cloud_") => cancel_cloud_job(id),
79 Some(id) if is_cancel_all_token(id) => {
80 CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
81 }
82 Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel {
83 id: id.to_string(),
84 })),
85 None => CommandResult::error(
86 "Usage: /jobs cancel <id|all> — id is the shell_* shown on the Shells work-strip row",
87 ),
88 },
89 "cancel-all" | "kill-all" | "stop-all" => {
90 CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
91 }
92 _ => CommandResult::error(
93 "Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <id>|cancel <id|all>]",
94 ),
95 }
96 }
97
98 fn show_cloud_job(id: &str) -> CommandResult {
99 match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.load(id)) {
100 Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
101 Err(error) => CommandResult::error(error.to_string()),
102 }
103 }
104
105 fn cancel_cloud_job(id: &str) -> CommandResult {
106 match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| {
107 crate::cloud_dispatch::cancel_job(&store, id, &crate::cloud_dispatch::LiveDaytonaLauncher)
108 }) {
109 Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
110 Err(error) => CommandResult::error(error.to_string()),
111 }
112 }
113
114 #[cfg(test)]
115 mod tests {
116 use super::*;
117
118 #[test]
119 fn parses_job_actions() {
120 let show = jobs(Some("show shell_abcd"));
121 assert!(matches!(
122 show.action,
123 Some(AppAction::ShellJob(ShellJobAction::Show { id })) if id == "shell_abcd"
124 ));
125
126 let send = jobs(Some("stdin shell_abcd y"));
127 assert!(matches!(
128 send.action,
129 Some(AppAction::ShellJob(ShellJobAction::SendStdin { id, input, close: false }))
130 if id == "shell_abcd" && input == "y"
131 ));
132
133 let cancel_all = jobs(Some("cancel-all"));
134 assert!(matches!(
135 cancel_all.action,
136 Some(AppAction::ShellJob(ShellJobAction::CancelAll))
137 ));
138
139 let cloud = jobs(Some("show cloud_deadbeef"));
140 assert!(cloud.action.is_none());
141 assert!(cloud.message.is_some() || cloud.is_error);
142
143 let cancel_all_spaced = jobs(Some("cancel all"));
144 assert!(matches!(
145 cancel_all_spaced.action,
146 Some(AppAction::ShellJob(ShellJobAction::CancelAll))
147 ));
148
149 let cancel_id = jobs(Some("cancel shell_abcd"));
150 assert!(matches!(
151 cancel_id.action,
152 Some(AppAction::ShellJob(ShellJobAction::Cancel { id })) if id == "shell_abcd"
153 ));
154
155 let bare_cancel = jobs(Some("cancel"));
156 assert!(bare_cancel.action.is_none());
157 assert!(
158 bare_cancel
159 .message
160 .as_deref()
161 .is_some_and(|message| message.contains("shell_*") && message.contains("<id|all>")),
162 "bare cancel must name the work-strip id space, not a task: {:?}",
163 bare_cancel.message
164 );
165 assert!(
166 !bare_cancel
167 .message
168 .as_deref()
169 .is_some_and(|message| message.contains("Task")),
170 "bare cancel must not mention Task: {:?}",
171 bare_cancel.message
172 );
173 }
174
175 #[test]
176 fn handler_is_pure_and_argument_only() {
177 assert!(matches!(JobsCmd::handler(), CommandHandler::Pure(_)));
178 assert_eq!(JobsCmd::info().description_key, "cmd_jobs_description");
179 assert_eq!(JobsCmd::info().aliases, &["job", "zuoye"]);
180 }
181 }
182
182 lines RUST