1use super::lock::{command_lock, workspace_progress_command};
2use super::purge::{resolve_purge_action, ResolvedPurgeAction};
3use super::push::push_confirmed;
4use crate::cli::{
5 ArchiveAction, ArchiveCaseAction, ArchiveCaseNotesAction, ArchiveListAction,
6 ArchiveMessageCommand, ArchiveMessageNotesAction, CaseCommand, CaseDraftAction,
7 CaseNotesAction, Command, ConfigAction, ContactCommand, ContactEmailAction, ContactNotesAction,
8 ContactPhoneAction, DoctorAction, LogAction, MessageAction, MessageAttachmentAction,
9 PushAction, RemoteAction, RenderAction, TriageAction,
10};
11use crate::error::{AppError, Result};
12use crate::progress::{ProgressCallback, WorkspaceProgressSink};
13use crate::store::{DraftChange, NewDraft, Workspace};
14use crate::workspace_lock::{LockMode, WorkspaceLock};
15use serde_json::{json, Value};
16use std::path::Path;
17
18pub fn execute_command(command: Command) -> Result<Value> {
19 execute_command_with_progress(command, None)
20}
21
22pub(super) fn execute_command_with_progress(
23 command: Command,
24 progress: Option<&mut ProgressCallback<'_>>,
25) -> Result<Value> {
26 match command {
27 Command::Skill { action } => crate::skill_admin::handle_action(action),
28 Command::Status => {
29 let cwd = std::env::current_dir().map_err(|e| AppError::io("current dir", &e))?;
30 execute_status(&cwd)
31 }
32 Command::Init { .. } => {
33 let cwd = std::env::current_dir().map_err(|e| AppError::io("current dir", &e))?;
34 execute_command_unlocked(command, &cwd, progress)
35 }
36 command => {
37 let cwd = std::env::current_dir().map_err(|e| AppError::io("current dir", &e))?;
38 let lock = command_lock(&command, &cwd)?;
39 let _lock = WorkspaceLock::acquire(&lock.root, lock.mode)?;
40 let mut workspace_progress = workspace_progress_command(&command)
41 .map(|name| WorkspaceProgressSink::start(&lock.root, name));
42 if lock.mode == LockMode::Exclusive
43 && !matches!(command, Command::Init { .. } | Command::Doctor { .. })
44 {
45 if let Err(err) = Workspace::at(&lock.root).ensure_no_incomplete_transactions() {
46 if let Some(sink) = &mut workspace_progress {
47 sink.finish_failure(&err);
48 }
49 return Err(err);
50 }
51 }
52 let mut progress = progress;
53 let should_emit_progress = progress.is_some() || workspace_progress.is_some();
54 let result = {
55 let mut emit_progress = |phase: &str, fields: Value| {
56 if let Some(callback) = progress.as_deref_mut() {
57 callback(phase, fields.clone());
58 }
59 if let Some(sink) = &mut workspace_progress {
60 sink.update(phase, fields);
61 }
62 };
63 let progress = if should_emit_progress {
64 Some(&mut emit_progress as &mut ProgressCallback<'_>)
65 } else {
66 None
67 };
68 execute_command_unlocked(command, &cwd, progress)
69 };
70 if let Some(sink) = &mut workspace_progress {
71 match &result {
72 Ok(value) => sink.finish_success(value),
73 Err(err) => sink.finish_failure(err),
74 }
75 }
76 result
77 }
78 }
79}
80
81fn execute_command_unlocked(
82 command: Command,
83 cwd: &Path,
84 progress: Option<&mut ProgressCallback<'_>>,
85) -> Result<Value> {
86 match command {
87 Command::Init { path } => Workspace::init_command(cwd, path.as_deref()),
88 Command::Pull { ids } => Workspace::discover(cwd)?.pull_with_progress(&ids, progress),
89 Command::Config { action } => {
90 let ws = Workspace::discover(cwd)?;
91 match action {
92 ConfigAction::Show => ws.config_show(),
93 ConfigAction::Get { key } => ws.config_get(&key),
94 ConfigAction::Set { key, values } => ws.config_set(&key, &values),
95 }
96 }
97 Command::Remote { action } => {
98 let ws = Workspace::discover(cwd)?;
99 match action {
100 RemoteAction::Test => ws.remote_test(),
101 RemoteAction::Folders => ws.remote_folders(),
102 }
103 }
104 Command::Push {
105 dry_run,
106 confirm,
107 action,
108 } => {
109 let ws = Workspace::discover(cwd)?;
110 match action {
111 Some(PushAction::List) => ws.push_list(),
112 None => {
113 let confirmed = push_confirmed(dry_run, confirm)?;
114 ws.push_with_progress(confirmed, progress)
115 }
116 }
117 }
118 Command::Status => execute_status(cwd),
119 Command::Doctor { action } => {
120 let ws = Workspace::discover(cwd)?;
121 match action {
122 None => ws.doctor(),
123 Some(DoctorAction::Repair { confirm }) => ws.doctor_repair(confirm),
124 }
125 }
126 Command::Purge {
127 action,
128 older_than_days,
129 } => {
130 let ws = Workspace::discover(cwd)?;
131 match resolve_purge_action(action, older_than_days)? {
132 ResolvedPurgeAction::Spam { older_than_days } => ws.purge_spam(older_than_days),
133 ResolvedPurgeAction::Trash { older_than_days } => ws.purge_trash(older_than_days),
134 ResolvedPurgeAction::Deleted { older_than_days } => {
135 ws.purge_deleted(older_than_days)
136 }
137 ResolvedPurgeAction::Discards { older_than_days } => {
138 ws.purge_discards(older_than_days)
139 }
140 }
141 }
142 Command::Skill { action } => crate::skill_admin::handle_action(action),
143 Command::Triage { action } => {
144 let ws = Workspace::discover(cwd)?;
145 match action {
146 TriageAction::List => ws.triage_list(),
147 }
148 }
149 Command::Message { action } => {
150 let ws = Workspace::discover(cwd)?;
151 match action {
152 MessageAction::Show { message_id } => ws.message_show(&message_id),
153 MessageAction::Spam { message_id, reason } => {
154 ws.spam_message(&message_id, reason.as_deref())
155 }
156 MessageAction::Trash { message_id, reason } => {
157 ws.trash_message(&message_id, reason.as_deref())
158 }
159 MessageAction::Restore { message_id, reason } => {
160 ws.restore_message(&message_id, reason.as_deref())
161 }
162 MessageAction::Attachment { action } => match action {
163 MessageAttachmentAction::Fetch {
164 message_id,
165 part_id,
166 } => ws.fetch_message_attachment(&message_id, part_id.as_deref()),
167 },
168 }
169 }
170 Command::Case { action } => {
171 let ws = Workspace::discover(cwd)?;
172 match action {
173 CaseCommand::Create(args) => ws.create_case(
174 &args.name,
175 args.group.as_deref(),
176 args.message.as_deref(),
177 args.summary.as_deref(),
178 args.reason.as_deref(),
179 ),
180 CaseCommand::List => ws.case_list(),
181 CaseCommand::Show { case_ref } => ws.active_case_show(&case_ref),
182 CaseCommand::Add {
183 case_ref,
184 message_id,
185 summary,
186 reason,
187 } => ws.add_message_to_case(
188 &case_ref,
189 &message_id,
190 summary.as_deref(),
191 reason.as_deref(),
192 ),
193 CaseCommand::Move { case_ref, group } => ws.move_case(&case_ref, &group),
194 CaseCommand::Rename {
195 case_ref,
196 name,
197 reason,
198 } => ws.rename_active_case(&case_ref, &name, reason.as_deref()),
199 CaseCommand::Notes { action } => match action {
200 CaseNotesAction::Show { case_ref } => ws.active_case_notes_show(&case_ref),
201 CaseNotesAction::Append { case_ref, text } => {
202 ws.active_case_notes_append(&case_ref, &text)
203 }
204 CaseNotesAction::Replace { case_ref, text } => {
205 ws.active_case_notes_replace(&case_ref, &text)
206 }
207 },
208 CaseCommand::Archive { case_ref, reason } => {
209 ws.archive_case(&case_ref, reason.as_deref())
210 }
211 CaseCommand::Reopen { case_ref, reason } => {
212 ws.reopen_case(&case_ref, reason.as_deref())
213 }
214 CaseCommand::Tag {
215 case_ref,
216 tag,
217 reason,
218 } => ws.tag_case(&case_ref, &tag, reason.as_deref()),
219 CaseCommand::Untag {
220 case_ref,
221 tag,
222 reason,
223 } => ws.untag_case(&case_ref, &tag, reason.as_deref()),
224 CaseCommand::Draft { action } => match action {
225 CaseDraftAction::New {
226 case_ref,
227 to,
228 cc,
229 subject,
230 identity,
231 body,
232 body_file,
233 } => ws.create_draft(
234 &case_ref,
235 NewDraft {
236 to: &to,
237 cc: &cc,
238 subject: &subject,
239 identity: identity.as_deref(),
240 body: body.as_deref(),
241 body_file: body_file.as_deref(),
242 },
243 ),
244 CaseDraftAction::Reply {
245 case_ref,
246 message_id,
247 identity,
248 body,
249 body_file,
250 all,
251 } => ws.reply_to_message(
252 &case_ref,
253 &message_id,
254 all,
255 identity.as_deref(),
256 body.as_deref(),
257 body_file.as_deref(),
258 ),
259 CaseDraftAction::Change {
260 case_ref,
261 draft_name,
262 subject,
263 to,
264 cc,
265 clear_cc,
266 identity,
267 body,
268 body_file,
269 } => ws.change_draft(
270 &case_ref,
271 &draft_name,
272 DraftChange {
273 subject: subject.as_deref(),
274 to: &to,
275 cc: &cc,
276 clear_cc,
277 identity: identity.as_deref(),
278 body: body.as_deref(),
279 body_file: body_file.as_deref(),
280 },
281 ),
282 CaseDraftAction::Show {
283 case_ref,
284 draft_name,
285 } => ws.show_draft(&case_ref, &draft_name),
286 CaseDraftAction::Validate {
287 case_ref,
288 draft_name,
289 } => ws.validate_draft(&case_ref, &draft_name),
290 CaseDraftAction::Save {
291 case_ref,
292 draft_name,
293 } => ws.save_draft(&case_ref, &draft_name),
294 CaseDraftAction::Send {
295 case_ref,
296 draft_name,
297 } => ws.send_draft(&case_ref, &draft_name),
298 CaseDraftAction::Attach {
299 case_ref,
300 draft_name,
301 path,
302 } => ws.attach_file_to_draft(&case_ref, &draft_name, &path),
303 CaseDraftAction::Remove {
304 case_ref,
305 draft_name,
306 reason,
307 } => ws.remove_draft(&case_ref, &draft_name, reason.as_deref()),
308 },
309 CaseCommand::Merge {
310 case_ref,
311 other_case_ref,
312 reason,
313 } => ws.merge_case(&case_ref, &other_case_ref, reason.as_deref()),
314 }
315 }
316 Command::Contact { action } => {
317 use crate::store::contacts::{ContactExtractSource, NewContact};
318 let ws = Workspace::discover(cwd)?;
319 match action {
320 ContactCommand::Create(args) => ws.contact_create(NewContact {
321 name: &args.name,
322 group: args.group.as_deref(),
323 emails: &args.emails,
324 phones: &args.phones,
325 org: args.org.as_deref(),
326 role: args.role.as_deref(),
327 tags: &args.tags,
328 }),
329 ContactCommand::List {
330 group,
331 tag,
332 org,
333 role,
334 } => ws.contact_list(
335 group.as_deref(),
336 tag.as_deref(),
337 org.as_deref(),
338 role.as_deref(),
339 ),
340 ContactCommand::Show { contact_ref } => ws.contact_show(&contact_ref),
341 ContactCommand::Move { contact_ref, group } => {
342 ws.contact_move(&contact_ref, &group)
343 }
344 ContactCommand::Rename { contact_ref, name } => {
345 ws.contact_rename(&contact_ref, &name)
346 }
347 ContactCommand::Email { action } => match action {
348 ContactEmailAction::Add { contact_ref, email } => {
349 ws.contact_email_add(&contact_ref, &email)
350 }
351 ContactEmailAction::Remove { contact_ref, email } => {
352 ws.contact_email_remove(&contact_ref, &email)
353 }
354 },
355 ContactCommand::Phone { action } => match action {
356 ContactPhoneAction::Add { contact_ref, phone } => {
357 ws.contact_phone_add(&contact_ref, &phone)
358 }
359 ContactPhoneAction::Remove { contact_ref, phone } => {
360 ws.contact_phone_remove(&contact_ref, &phone)
361 }
362 },
363 ContactCommand::Tag { contact_ref, tag } => ws.contact_tag(&contact_ref, &tag),
364 ContactCommand::Untag { contact_ref, tag } => ws.contact_untag(&contact_ref, &tag),
365 ContactCommand::Notes { action } => match action {
366 ContactNotesAction::Show { contact_ref } => ws.contact_notes_show(&contact_ref),
367 ContactNotesAction::Append { contact_ref, text } => {
368 ws.contact_notes_append(&contact_ref, &text)
369 }
370 ContactNotesAction::Replace { contact_ref, text } => {
371 ws.contact_notes_replace(&contact_ref, &text)
372 }
373 },
374 ContactCommand::Archive {
375 contact_ref,
376 reason,
377 } => ws.contact_archive_contact(&contact_ref, reason.as_deref()),
378 ContactCommand::Reopen {
379 contact_ref,
380 group,
381 reason,
382 } => ws.contact_reopen_contact(&contact_ref, group.as_deref(), reason.as_deref()),
383 ContactCommand::Extract {
384 from_triage,
385 from_case,
386 all,
387 group,
388 } => {
389 let source = if from_triage {
390 ContactExtractSource::FromTriage
391 } else if let Some(case_ref) = from_case {
392 ContactExtractSource::FromCase(case_ref)
393 } else if all {
394 ContactExtractSource::All
395 } else {
396 ContactExtractSource::FromTriage
397 };
398 ws.contact_extract(source, group.as_deref())
399 }
400 }
401 }
402 Command::Archive { action } => {
403 let ws = Workspace::discover(cwd)?;
404 match action {
405 ArchiveAction::List { target: None } => ws.archive_list(),
406 ArchiveAction::List {
407 target: Some(ArchiveListAction::Cases),
408 } => ws.archive_list_cases(),
409 ArchiveAction::List {
410 target: Some(ArchiveListAction::Messages),
411 } => ws.archive_list_messages(),
412 ArchiveAction::Message { action } => match action {
413 ArchiveMessageCommand::Create(args) => ws.create_archive_message_category(
414 &args.name,
415 args.message.as_deref(),
416 args.summary.as_deref(),
417 args.reason.as_deref(),
418 ),
419 ArchiveMessageCommand::Add {
420 archive_ref,
421 message_id,
422 summary,
423 reason,
424 } => ws.archive_message(
425 &message_id,
426 &archive_ref,
427 Some(summary.as_str()),
428 reason.as_deref(),
429 ),
430 ArchiveMessageCommand::Show { archive_ref } => {
431 ws.archive_message_show(&archive_ref)
432 }
433 ArchiveMessageCommand::Restore {
434 archive_ref,
435 message_id,
436 reason,
437 } => ws.archive_message_restore(&archive_ref, &message_id, reason.as_deref()),
438 ArchiveMessageCommand::Move {
439 archive_ref,
440 message_id,
441 new_archive_ref,
442 reason,
443 } => ws.archive_message_move(
444 &archive_ref,
445 &message_id,
446 &new_archive_ref,
447 reason.as_deref(),
448 ),
449 ArchiveMessageCommand::Rename {
450 archive_ref,
451 name,
452 reason,
453 } => ws.archive_message_rename(&archive_ref, &name, reason.as_deref()),
454 ArchiveMessageCommand::SetSummary {
455 archive_ref,
456 message_id,
457 summary,
458 reason,
459 } => ws.archive_message_set_summary(
460 &archive_ref,
461 &message_id,
462 &summary,
463 reason.as_deref(),
464 ),
465 ArchiveMessageCommand::Notes { action } => match action {
466 ArchiveMessageNotesAction::Show { archive_ref } => {
467 ws.archive_message_notes_show(&archive_ref)
468 }
469 ArchiveMessageNotesAction::Append { archive_ref, text } => {
470 ws.archive_message_notes_append(&archive_ref, &text)
471 }
472 ArchiveMessageNotesAction::Replace { archive_ref, text } => {
473 ws.archive_message_notes_replace(&archive_ref, &text)
474 }
475 },
476 },
477 ArchiveAction::Case { action } => match action {
478 ArchiveCaseAction::Show { case_ref } => ws.archive_case_show(&case_ref),
479 ArchiveCaseAction::Restore {
480 case_ref,
481 group,
482 reason,
483 } => ws.archive_case_restore(&case_ref, &group, reason.as_deref()),
484 ArchiveCaseAction::Rename {
485 case_ref,
486 name,
487 reason,
488 } => ws.archive_case_rename(&case_ref, &name, reason.as_deref()),
489 ArchiveCaseAction::Notes { action } => match action {
490 ArchiveCaseNotesAction::Show { case_ref } => {
491 ws.archive_case_notes_show(&case_ref)
492 }
493 ArchiveCaseNotesAction::Append { case_ref, text } => {
494 ws.archive_case_notes_append(&case_ref, &text)
495 }
496 ArchiveCaseNotesAction::Replace { case_ref, text } => {
497 ws.archive_case_notes_replace(&case_ref, &text)
498 }
499 },
500 },
501 }
502 }
503 Command::Render { action } => {
504 let ws = Workspace::discover(cwd)?;
505 match action {
506 RenderAction::Refresh => ws.render_refresh(),
507 RenderAction::Templates { force } => ws.render_templates(force),
508 }
509 }
510 Command::Log { action } => {
511 let ws = Workspace::discover(cwd)?;
512 match action {
513 LogAction::List { limit } => ws.log_list(limit),
514 LogAction::Tail => ws.log_tail(),
515 LogAction::Message { message_id } => ws.log_message(&message_id),
516 LogAction::Case { case_uid } => ws.log_case(&case_uid),
517 LogAction::Archive { archive_uid } => ws.log_archive(&archive_uid),
518 }
519 }
520 #[cfg(feature = "ui")]
521 Command::Ui(_) => {
522 let ws = Workspace::discover(cwd)?;
523 let message = crate::ui::workspace_snapshot(&ws)?;
524 serde_json::to_value(message).map_err(|e| AppError::json("serialize AFUI snapshot", &e))
525 }
526 }
527}
528
529fn execute_status(cwd: &Path) -> Result<Value> {
530 let ws = Workspace::discover(cwd)?;
531 let progress = crate::progress::workspace_status_progress(ws.root())?;
532 let Some(_lock) = WorkspaceLock::try_acquire(ws.root(), LockMode::Shared)? else {
533 return Ok(json!({
534 "code": "status",
535 "workspace_locked": true,
536 "progress": progress,
537 "hint": "Workspace counts are omitted while another afmail command is using this workspace; retry after it finishes for full counts."
538 }));
539 };
540 let mut status = ws.status()?;
541 if let Value::Object(map) = &mut status {
542 map.insert("progress".to_string(), progress);
543 }
544 Ok(status)
545}