Skip to main content

wekan_cli/
output.rs

1mod attachments;
2mod auth;
3mod boards;
4mod cards;
5mod checklist_items;
6mod checklists;
7mod comments;
8mod custom_field_items;
9mod custom_fields;
10mod dependencies;
11mod formatting;
12mod lists;
13mod profiles;
14mod raw;
15mod swimlanes;
16mod users;
17
18use auth::{render_auth_status, render_auth_success, render_logout};
19use boards::{
20    render_board_count, render_board_created, render_board_deleted, render_board_detail,
21    render_board_list, render_board_renamed,
22};
23use cards::{
24    render_card_archived, render_card_collection, render_card_created, render_card_deleted,
25    render_card_detail, render_card_restored, render_card_updated,
26};
27use checklist_items::{
28    render_checklist_item_created, render_checklist_item_deleted, render_checklist_item_detail,
29    render_checklist_item_updated,
30};
31use checklists::{
32    render_checklist_collection, render_checklist_created, render_checklist_deleted,
33    render_checklist_detail,
34};
35use comments::{
36    render_comment_collection, render_comment_created, render_comment_deleted,
37    render_comment_detail,
38};
39use lists::{
40    render_list_collection, render_list_created, render_list_deleted, render_list_detail,
41    render_list_updated,
42};
43use profiles::{render_profile_item, render_profile_list, render_profile_removed};
44use swimlanes::{
45    render_swimlane_collection, render_swimlane_created, render_swimlane_deleted,
46    render_swimlane_detail, render_swimlane_updated,
47};
48use users::{
49    render_user_boards, render_user_cards, render_user_created, render_user_deleted,
50    render_user_detail, render_user_list, render_user_login_change, render_user_ownership,
51};
52
53use std::{ffi::OsString, io::Write, process::ExitCode};
54
55use clap::ValueEnum;
56use serde::Serialize;
57
58use crate::command_result::CommandSuccess;
59use crate::{
60    error::{AppError, ErrorCode},
61    exit_code::StableExitCode,
62};
63
64pub(crate) use raw::render_human_response as render_api_response_human;
65#[cfg(test)]
66pub(crate) use raw::structured_api_response;
67
68#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
69#[value(rename_all = "lower")]
70pub enum OutputFormat {
71    #[default]
72    Human,
73    Json,
74    Raw,
75}
76
77impl OutputFormat {
78    pub fn detect_from_args(args: &[OsString]) -> Self {
79        let mut iter = args.iter().filter_map(|arg| arg.to_str());
80        while let Some(arg) = iter.next() {
81            if arg == "--output" {
82                return match iter.next() {
83                    Some("json") => Self::Json,
84                    Some("raw") => Self::Raw,
85                    _ => Self::Human,
86                };
87            }
88            if arg == "--output=json" {
89                return Self::Json;
90            }
91            if arg == "--output=raw" {
92                return Self::Raw;
93            }
94        }
95        Self::Human
96    }
97}
98
99#[derive(Serialize)]
100struct SuccessEnvelope<'a, T> {
101    ok: bool,
102    data: &'a T,
103}
104
105#[derive(Serialize)]
106struct ErrorEnvelope<'a> {
107    ok: bool,
108    error: ErrorBody<'a>,
109}
110
111#[derive(Serialize)]
112struct ErrorBody<'a> {
113    code: &'static str,
114    message: &'a str,
115    details: &'a crate::error::ErrorDetails,
116}
117
118pub fn render_success(format: OutputFormat, success: &CommandSuccess) -> String {
119    match (format, success) {
120        (OutputFormat::Human, CommandSuccess::CardBatch(data)) => cards::render_batch(data),
121        (OutputFormat::Json, CommandSuccess::CardBatch(data)) => match data {
122            crate::command_result::CardBatchResult::CardBulkCreate(data) => {
123                render_json_success(data)
124            }
125            crate::command_result::CardBatchResult::CardBulkDelete(data) => {
126                render_json_success(data)
127            }
128            crate::command_result::CardBatchResult::CardBulkLabels(data) => {
129                render_json_success(data)
130            }
131        },
132        (OutputFormat::Human, CommandSuccess::BoardLabels(data)) => boards::render_membership(data),
133        (OutputFormat::Json, CommandSuccess::BoardLabels(data)) => render_json_success(data),
134        (OutputFormat::Human, CommandSuccess::BoardLabelAdded(data)) => {
135            boards::render_label_added(data)
136        }
137        (OutputFormat::Json, CommandSuccess::BoardLabelAdded(data)) => render_json_success(data),
138        (OutputFormat::Human, CommandSuccess::CardLabels(data)) => cards::render_membership(data),
139        (OutputFormat::Json, CommandSuccess::CardLabels(data)) => render_json_success(data),
140        (OutputFormat::Human, CommandSuccess::BoardMembers(data)) => {
141            boards::render_membership(data)
142        }
143        (OutputFormat::Json, CommandSuccess::BoardMembers(data)) => render_json_success(data),
144        (OutputFormat::Human, CommandSuccess::BoardMemberChanged(data)) => {
145            boards::render_membership(data)
146        }
147        (OutputFormat::Json, CommandSuccess::BoardMemberChanged(data)) => render_json_success(data),
148        (OutputFormat::Human, CommandSuccess::BoardMemberUpdated(data)) => {
149            boards::render_membership(data)
150        }
151        (OutputFormat::Json, CommandSuccess::BoardMemberUpdated(data)) => render_json_success(data),
152        (OutputFormat::Human, CommandSuccess::CardMembers(data)) => cards::render_membership(data),
153        (OutputFormat::Json, CommandSuccess::CardMembers(data)) => render_json_success(data),
154        (OutputFormat::Human, CommandSuccess::CardAssignees(data)) => {
155            cards::render_membership(data)
156        }
157        (OutputFormat::Json, CommandSuccess::CardAssignees(data)) => render_json_success(data),
158        (OutputFormat::Human, CommandSuccess::CardMembershipChanged(data)) => {
159            cards::render_membership(data)
160        }
161        (OutputFormat::Json, CommandSuccess::CardMembershipChanged(data)) => {
162            render_json_success(data)
163        }
164        (OutputFormat::Human, CommandSuccess::CardDependencies(data)) => {
165            dependencies::card_collection(data)
166        }
167        (OutputFormat::Json, CommandSuccess::CardDependencies(data)) => render_json_success(data),
168        (OutputFormat::Human, CommandSuccess::BoardDependencies(data)) => {
169            dependencies::board_collection(data)
170        }
171        (OutputFormat::Json, CommandSuccess::BoardDependencies(data)) => render_json_success(data),
172        (OutputFormat::Human, CommandSuccess::DependencyCreated(data)) => {
173            dependencies::created(data)
174        }
175        (OutputFormat::Json, CommandSuccess::DependencyCreated(data)) => render_json_success(data),
176        (OutputFormat::Human, CommandSuccess::DependencyUpdated(data)) => {
177            dependencies::updated(data)
178        }
179        (OutputFormat::Json, CommandSuccess::DependencyUpdated(data)) => render_json_success(data),
180        (OutputFormat::Human, CommandSuccess::DependencyDeleted(data)) => {
181            dependencies::deleted(data)
182        }
183        (OutputFormat::Json, CommandSuccess::DependencyDeleted(data)) => render_json_success(data),
184        (OutputFormat::Human, CommandSuccess::AttachmentList(data)) => attachments::list(data),
185        (OutputFormat::Json, CommandSuccess::AttachmentList(data)) => render_json_success(data),
186        (OutputFormat::Human, CommandSuccess::AttachmentShown(data)) => attachments::detail(data),
187        (OutputFormat::Json, CommandSuccess::AttachmentShown(data)) => render_json_success(data),
188        (OutputFormat::Human, CommandSuccess::AttachmentUploaded(data)) => {
189            attachments::detail(data)
190        }
191        (OutputFormat::Json, CommandSuccess::AttachmentUploaded(data)) => render_json_success(data),
192        (OutputFormat::Human, CommandSuccess::BackgroundUploaded(data)) => {
193            attachments::detail(data)
194        }
195        (OutputFormat::Json, CommandSuccess::BackgroundUploaded(data)) => render_json_success(data),
196        (OutputFormat::Human, CommandSuccess::AttachmentCopied(data)) => attachments::detail(data),
197        (OutputFormat::Json, CommandSuccess::AttachmentCopied(data)) => render_json_success(data),
198        (OutputFormat::Human, CommandSuccess::AttachmentMoved(data)) => attachments::detail(data),
199        (OutputFormat::Json, CommandSuccess::AttachmentMoved(data)) => render_json_success(data),
200        (OutputFormat::Human, CommandSuccess::AttachmentDeleted(data)) => attachments::detail(data),
201        (OutputFormat::Json, CommandSuccess::AttachmentDeleted(data)) => render_json_success(data),
202        (OutputFormat::Human, CommandSuccess::AttachmentDownloaded(data)) => {
203            attachments::detail(data)
204        }
205        (OutputFormat::Json, CommandSuccess::AttachmentDownloaded(data)) => {
206            render_json_success(data)
207        }
208        (_, CommandSuccess::ApiResponse(_)) => {
209            panic!("streaming API responses must be consumed by output::write_success")
210        }
211        (OutputFormat::Raw, CommandSuccess::Cancelled(_)) => String::new(),
212        (OutputFormat::Raw, _) => panic!("raw results must be consumed by output::write_success"),
213        (OutputFormat::Human, CommandSuccess::Cancelled(_)) => {
214            "Cancelled; no changes made.".to_owned()
215        }
216        (OutputFormat::Json, CommandSuccess::Cancelled(data)) => render_json_success(data),
217        (OutputFormat::Human, CommandSuccess::Registration(data)) => {
218            render_auth_success("Registered user", data)
219        }
220        (OutputFormat::Json, CommandSuccess::Registration(data)) => render_json_success(data),
221        (OutputFormat::Human, CommandSuccess::Login(data)) => {
222            render_auth_success("Logged in user", data)
223        }
224        (OutputFormat::Json, CommandSuccess::Login(data)) => render_json_success(data),
225        (OutputFormat::Human, CommandSuccess::Logout(data)) => render_logout(data),
226        (OutputFormat::Json, CommandSuccess::Logout(data)) => render_json_success(data),
227        (OutputFormat::Human, CommandSuccess::AuthStatus(data)) => render_auth_status(data),
228        (OutputFormat::Json, CommandSuccess::AuthStatus(data)) => render_json_success(data),
229        (OutputFormat::Human, CommandSuccess::UserCurrent(data))
230        | (OutputFormat::Human, CommandSuccess::UserShown(data))
231        | (OutputFormat::Human, CommandSuccess::UserUpdated(data)) => render_user_detail(data),
232        (OutputFormat::Json, CommandSuccess::UserCurrent(data))
233        | (OutputFormat::Json, CommandSuccess::UserShown(data))
234        | (OutputFormat::Json, CommandSuccess::UserUpdated(data)) => render_json_success(data),
235        (OutputFormat::Human, CommandSuccess::UserList(data)) => render_user_list(data),
236        (OutputFormat::Json, CommandSuccess::UserList(data)) => render_json_success(data),
237        (OutputFormat::Human, CommandSuccess::UserCards(data)) => render_user_cards(data),
238        (OutputFormat::Json, CommandSuccess::UserCards(data)) => render_json_success(data),
239        (OutputFormat::Human, CommandSuccess::UserCreated(data)) => render_user_created(data),
240        (OutputFormat::Json, CommandSuccess::UserCreated(data)) => render_json_success(data),
241        (OutputFormat::Human, CommandSuccess::UserBoards(data)) => render_user_boards(data),
242        (OutputFormat::Json, CommandSuccess::UserBoards(data)) => render_json_success(data),
243        (OutputFormat::Human, CommandSuccess::UserOwnershipTaken(data)) => {
244            render_user_ownership(data)
245        }
246        (OutputFormat::Json, CommandSuccess::UserOwnershipTaken(data)) => render_json_success(data),
247        (OutputFormat::Human, CommandSuccess::UserLoginChanged(data)) => {
248            render_user_login_change(data)
249        }
250        (OutputFormat::Json, CommandSuccess::UserLoginChanged(data)) => render_json_success(data),
251        (OutputFormat::Human, CommandSuccess::UserDeleted(data)) => render_user_deleted(data),
252        (OutputFormat::Json, CommandSuccess::UserDeleted(data)) => render_json_success(data),
253        (OutputFormat::Human, CommandSuccess::BoardList(data)) => render_board_list(data),
254        (OutputFormat::Json, CommandSuccess::BoardList(data)) => render_json_success(data),
255        (OutputFormat::Human, CommandSuccess::BoardCount(data)) => render_board_count(data),
256        (OutputFormat::Json, CommandSuccess::BoardCount(data)) => render_json_success(data),
257        (OutputFormat::Human, CommandSuccess::BoardShown(data)) => render_board_detail(data),
258        (OutputFormat::Json, CommandSuccess::BoardShown(data)) => render_json_success(data),
259        (OutputFormat::Human, CommandSuccess::BoardCreated(data)) => render_board_created(data),
260        (OutputFormat::Json, CommandSuccess::BoardCreated(data)) => render_json_success(data),
261        (OutputFormat::Human, CommandSuccess::BoardRenamed(data)) => render_board_renamed(data),
262        (OutputFormat::Json, CommandSuccess::BoardRenamed(data)) => render_json_success(data),
263        (OutputFormat::Human, CommandSuccess::BoardDeleted(data)) => render_board_deleted(data),
264        (OutputFormat::Json, CommandSuccess::BoardDeleted(data)) => render_json_success(data),
265        (OutputFormat::Human, CommandSuccess::ListCollection(data)) => render_list_collection(data),
266        (OutputFormat::Json, CommandSuccess::ListCollection(data)) => render_json_success(data),
267        (OutputFormat::Human, CommandSuccess::ListShown(data)) => render_list_detail(data),
268        (OutputFormat::Json, CommandSuccess::ListShown(data)) => render_json_success(data),
269        (OutputFormat::Human, CommandSuccess::ListCreated(data)) => render_list_created(data),
270        (OutputFormat::Json, CommandSuccess::ListCreated(data)) => render_json_success(data),
271        (OutputFormat::Human, CommandSuccess::ListUpdated(data)) => render_list_updated(data),
272        (OutputFormat::Json, CommandSuccess::ListUpdated(data)) => render_json_success(data),
273        (OutputFormat::Human, CommandSuccess::ListDeleted(data)) => render_list_deleted(data),
274        (OutputFormat::Json, CommandSuccess::ListDeleted(data)) => render_json_success(data),
275        (OutputFormat::Human, CommandSuccess::CardCollection(data)) => render_card_collection(data),
276        (OutputFormat::Json, CommandSuccess::CardCollection(data)) => render_json_success(data),
277        (OutputFormat::Human, CommandSuccess::CardShown(data)) => render_card_detail(data),
278        (OutputFormat::Json, CommandSuccess::CardShown(data)) => render_json_success(data),
279        (OutputFormat::Human, CommandSuccess::CardCreated(data)) => render_card_created(data),
280        (OutputFormat::Json, CommandSuccess::CardCreated(data)) => render_json_success(data),
281        (OutputFormat::Human, CommandSuccess::CardUpdated(data)) => render_card_updated(data),
282        (OutputFormat::Json, CommandSuccess::CardUpdated(data)) => render_json_success(data),
283        (OutputFormat::Human, CommandSuccess::CardArchived(data)) => render_card_archived(data),
284        (OutputFormat::Json, CommandSuccess::CardArchived(data)) => render_json_success(data),
285        (OutputFormat::Human, CommandSuccess::CardRestored(data)) => render_card_restored(data),
286        (OutputFormat::Json, CommandSuccess::CardRestored(data)) => render_json_success(data),
287        (OutputFormat::Human, CommandSuccess::CardDeleted(data)) => render_card_deleted(data),
288        (OutputFormat::Json, CommandSuccess::CardDeleted(data)) => render_json_success(data),
289        (OutputFormat::Human, CommandSuccess::ChecklistCollection(data)) => {
290            render_checklist_collection(data)
291        }
292        (OutputFormat::Json, CommandSuccess::ChecklistCollection(data)) => {
293            render_json_success(data)
294        }
295        (OutputFormat::Human, CommandSuccess::ChecklistShown(data)) => {
296            render_checklist_detail(data)
297        }
298        (OutputFormat::Json, CommandSuccess::ChecklistShown(data)) => render_json_success(data),
299        (OutputFormat::Human, CommandSuccess::ChecklistCreated(data)) => {
300            render_checklist_created(data)
301        }
302        (OutputFormat::Json, CommandSuccess::ChecklistCreated(data)) => render_json_success(data),
303        (OutputFormat::Human, CommandSuccess::ChecklistDeleted(data)) => {
304            render_checklist_deleted(data)
305        }
306        (OutputFormat::Json, CommandSuccess::ChecklistDeleted(data)) => render_json_success(data),
307        (OutputFormat::Human, CommandSuccess::ChecklistItemShown(data)) => {
308            render_checklist_item_detail(data)
309        }
310        (OutputFormat::Json, CommandSuccess::ChecklistItemShown(data)) => render_json_success(data),
311        (OutputFormat::Human, CommandSuccess::ChecklistItemCreated(data)) => {
312            render_checklist_item_created(data)
313        }
314        (OutputFormat::Json, CommandSuccess::ChecklistItemCreated(data)) => {
315            render_json_success(data)
316        }
317        (OutputFormat::Human, CommandSuccess::ChecklistItemUpdated(data)) => {
318            render_checklist_item_updated(data)
319        }
320        (OutputFormat::Json, CommandSuccess::ChecklistItemUpdated(data)) => {
321            render_json_success(data)
322        }
323        (OutputFormat::Human, CommandSuccess::ChecklistItemDeleted(data)) => {
324            render_checklist_item_deleted(data)
325        }
326        (OutputFormat::Json, CommandSuccess::ChecklistItemDeleted(data)) => {
327            render_json_success(data)
328        }
329        (OutputFormat::Human, CommandSuccess::CustomFieldCollection(data)) => {
330            custom_fields::collection(data)
331        }
332        (OutputFormat::Json, CommandSuccess::CustomFieldCollection(data)) => {
333            render_json_success(data)
334        }
335        (OutputFormat::Human, CommandSuccess::CustomFieldShown(data)) => {
336            custom_fields::detail(data)
337        }
338        (OutputFormat::Json, CommandSuccess::CustomFieldShown(data)) => render_json_success(data),
339        (OutputFormat::Human, CommandSuccess::CustomFieldCreated(data)) => {
340            custom_fields::created(data)
341        }
342        (OutputFormat::Json, CommandSuccess::CustomFieldCreated(data)) => render_json_success(data),
343        (OutputFormat::Human, CommandSuccess::CustomFieldUpdated(data)) => {
344            custom_fields::updated(data)
345        }
346        (OutputFormat::Json, CommandSuccess::CustomFieldUpdated(data)) => render_json_success(data),
347        (OutputFormat::Human, CommandSuccess::CustomFieldDeleted(data)) => {
348            custom_fields::deleted(data)
349        }
350        (OutputFormat::Json, CommandSuccess::CustomFieldDeleted(data)) => render_json_success(data),
351        (OutputFormat::Human, CommandSuccess::CustomFieldItemsCreated(data)) => {
352            custom_field_items::created(data)
353        }
354        (OutputFormat::Json, CommandSuccess::CustomFieldItemsCreated(data)) => {
355            render_json_success(data)
356        }
357        (OutputFormat::Human, CommandSuccess::CustomFieldItemUpdated(data)) => {
358            custom_field_items::updated(data)
359        }
360        (OutputFormat::Json, CommandSuccess::CustomFieldItemUpdated(data)) => {
361            render_json_success(data)
362        }
363        (OutputFormat::Human, CommandSuccess::CustomFieldItemDeleted(data)) => {
364            custom_field_items::deleted(data)
365        }
366        (OutputFormat::Json, CommandSuccess::CustomFieldItemDeleted(data)) => {
367            render_json_success(data)
368        }
369        (OutputFormat::Human, CommandSuccess::CardCustomFieldSet(data)) => {
370            cards::render_card_custom_field_set(data)
371        }
372        (OutputFormat::Json, CommandSuccess::CardCustomFieldSet(data)) => render_json_success(data),
373        (OutputFormat::Human, CommandSuccess::CardCustomFieldQuery(data)) => {
374            cards::render_card_custom_field_query(data)
375        }
376        (OutputFormat::Json, CommandSuccess::CardCustomFieldQuery(data)) => {
377            render_json_success(data)
378        }
379        (OutputFormat::Human, CommandSuccess::CommentCollection(data)) => {
380            render_comment_collection(data)
381        }
382        (OutputFormat::Json, CommandSuccess::CommentCollection(data)) => render_json_success(data),
383        (OutputFormat::Human, CommandSuccess::CommentShown(data)) => render_comment_detail(data),
384        (OutputFormat::Json, CommandSuccess::CommentShown(data)) => render_json_success(data),
385        (OutputFormat::Human, CommandSuccess::CommentCreated(data)) => render_comment_created(data),
386        (OutputFormat::Json, CommandSuccess::CommentCreated(data)) => render_json_success(data),
387        (OutputFormat::Human, CommandSuccess::CommentDeleted(data)) => render_comment_deleted(data),
388        (OutputFormat::Json, CommandSuccess::CommentDeleted(data)) => render_json_success(data),
389        (OutputFormat::Human, CommandSuccess::SwimlaneCollection(data)) => {
390            render_swimlane_collection(data)
391        }
392        (OutputFormat::Json, CommandSuccess::SwimlaneCollection(data)) => render_json_success(data),
393        (OutputFormat::Human, CommandSuccess::SwimlaneShown(data)) => render_swimlane_detail(data),
394        (OutputFormat::Json, CommandSuccess::SwimlaneShown(data)) => render_json_success(data),
395        (OutputFormat::Human, CommandSuccess::SwimlaneCreated(data)) => {
396            render_swimlane_created(data)
397        }
398        (OutputFormat::Json, CommandSuccess::SwimlaneCreated(data)) => render_json_success(data),
399        (OutputFormat::Human, CommandSuccess::SwimlaneUpdated(data)) => {
400            render_swimlane_updated(data)
401        }
402        (OutputFormat::Json, CommandSuccess::SwimlaneUpdated(data)) => render_json_success(data),
403        (OutputFormat::Human, CommandSuccess::SwimlaneDeleted(data)) => {
404            render_swimlane_deleted(data)
405        }
406        (OutputFormat::Json, CommandSuccess::SwimlaneDeleted(data)) => render_json_success(data),
407        (OutputFormat::Human, CommandSuccess::ProfileAdded(data)) => {
408            render_profile_item("Added profile", data)
409        }
410        (OutputFormat::Json, CommandSuccess::ProfileAdded(data))
411        | (OutputFormat::Json, CommandSuccess::ProfileShown(data))
412        | (OutputFormat::Json, CommandSuccess::ProfileUsed(data))
413        | (OutputFormat::Json, CommandSuccess::ProfileUpdated(data)) => {
414            serde_json::to_string(&SuccessEnvelope { ok: true, data })
415                .expect("profile success is always serializable")
416        }
417        (OutputFormat::Human, CommandSuccess::ProfileList(data)) => render_profile_list(data),
418        (OutputFormat::Json, CommandSuccess::ProfileList(data)) => {
419            serde_json::to_string(&SuccessEnvelope { ok: true, data })
420                .expect("profile list success is always serializable")
421        }
422        (OutputFormat::Human, CommandSuccess::ProfileShown(data)) => {
423            render_profile_item("Profile", data)
424        }
425        (OutputFormat::Human, CommandSuccess::ProfileUsed(data)) => {
426            render_profile_item("Using profile", data)
427        }
428        (OutputFormat::Human, CommandSuccess::ProfileUpdated(data)) => {
429            render_profile_item("Updated profile", data)
430        }
431        (OutputFormat::Human, CommandSuccess::ProfileRemoved(data)) => render_profile_removed(data),
432        (OutputFormat::Json, CommandSuccess::ProfileRemoved(data)) => {
433            serde_json::to_string(&SuccessEnvelope { ok: true, data })
434                .expect("profile removal success is always serializable")
435        }
436    }
437}
438
439pub async fn write_success(
440    format: OutputFormat,
441    success: CommandSuccess,
442    stdout: &mut dyn Write,
443    stderr: &mut dyn Write,
444) -> Result<ExitCode, AppError> {
445    if let CommandSuccess::ApiResponse(response) = success {
446        return raw::write_response(format, response, stdout, stderr).await;
447    }
448    if format == OutputFormat::Raw {
449        if let CommandSuccess::AttachmentDownloaded(data) = &success {
450            let bytes = data.bytes.as_deref().ok_or_else(|| {
451                AppError::invalid_input("raw attachment output requires --destination -")
452            })?;
453            stdout
454                .write_all(bytes)
455                .and_then(|()| stdout.flush())
456                .map_err(output_write_error)?;
457            return Ok(ExitCode::SUCCESS);
458        }
459    }
460    let rendered = render_success(format, &success);
461    if !rendered.is_empty() {
462        let written = writeln!(stdout, "{rendered}").and_then(|()| {
463            if matches!(&success, CommandSuccess::AttachmentDownloaded(data) if data.file_saved) {
464                stdout.flush()
465            } else {
466                Ok(())
467            }
468        });
469        written.map_err(|e| {
470            let error = output_write_error(e);
471            if let CommandSuccess::AttachmentDownloaded(data) = &success {
472                if data.file_saved {
473                    return error.with_details(crate::error::ErrorDetails {
474                        file_saved: Some(true),
475                        destination: Some(data.destination.clone()),
476                        ..crate::error::ErrorDetails::default()
477                    });
478                }
479            }
480            error
481        })?;
482    }
483    Ok(ExitCode::SUCCESS)
484}
485pub(crate) fn output_write_error(error: std::io::Error) -> AppError {
486    AppError::new(
487        ErrorCode::InternalError,
488        format!("could not write command output: {error}"),
489        StableExitCode::Transport,
490    )
491}
492
493pub(crate) fn render_json_success<T: Serialize>(data: &T) -> String {
494    serde_json::to_string(&SuccessEnvelope { ok: true, data })
495        .expect("command success is always serializable")
496}
497
498pub fn render_error(format: OutputFormat, error: &AppError) -> String {
499    match format {
500        OutputFormat::Human => {
501            let mut rendered = format!(
502                "Error [{}]: {}",
503                error.code().as_str(),
504                error.message().trim()
505            );
506            if let Some(batch) = &error.details().batch_result {
507                rendered.push('\n');
508                rendered.push_str(&cards::render_batch(batch));
509            }
510            if let Some(ids) = &error.details().invalid_label_ids {
511                rendered.push_str(&format!(
512                    "\nInvalid label IDs: {}",
513                    formatting::escape_terminal_controls(&ids.join(", "))
514                ));
515            }
516            if let Some(response) = &error.details().response {
517                rendered.push('\n');
518                rendered.push_str(&render_api_response_human(response));
519            }
520            if error.details().file_saved == Some(true) {
521                if let Some(destination) = &error.details().destination {
522                    rendered.push_str(&format!(
523                        "\nDownload saved to `{}`; completion output failed.",
524                        formatting::escape_terminal_controls(destination)
525                    ));
526                }
527            }
528            rendered
529        }
530        OutputFormat::Json => serde_json::to_string(&ErrorEnvelope {
531            ok: false,
532            error: ErrorBody {
533                code: error.code().as_str(),
534                message: error.message(),
535                details: error.details(),
536            },
537        })
538        .expect("application errors are always serializable"),
539        OutputFormat::Raw => format!(
540            "Error [{}]: {}",
541            error.code().as_str(),
542            error.message().trim()
543        ),
544    }
545}
546
547#[cfg(test)]
548mod tests;