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 imports;
13mod integrations;
14mod lists;
15mod profiles;
16mod raw;
17mod rules;
18mod skills;
19mod swimlanes;
20mod users;
21
22use auth::{render_auth_status, render_auth_success, render_logout};
23use boards::{
24    render_board_count, render_board_created, render_board_deleted, render_board_detail,
25    render_board_list, render_board_renamed,
26};
27use cards::{
28    render_card_archived, render_card_collection, render_card_created, render_card_deleted,
29    render_card_detail, render_card_restored, render_card_updated,
30};
31use checklist_items::{
32    render_checklist_item_created, render_checklist_item_deleted, render_checklist_item_detail,
33    render_checklist_item_updated,
34};
35use checklists::{
36    render_checklist_collection, render_checklist_created, render_checklist_deleted,
37    render_checklist_detail,
38};
39use comments::{
40    render_comment_collection, render_comment_created, render_comment_deleted,
41    render_comment_detail,
42};
43use lists::{
44    render_list_collection, render_list_created, render_list_deleted, render_list_detail,
45    render_list_updated,
46};
47use profiles::{render_profile_item, render_profile_list, render_profile_removed};
48use swimlanes::{
49    render_swimlane_collection, render_swimlane_created, render_swimlane_deleted,
50    render_swimlane_detail, render_swimlane_updated,
51};
52use users::{
53    render_user_boards, render_user_cards, render_user_created, render_user_deleted,
54    render_user_detail, render_user_list, render_user_login_change, render_user_ownership,
55};
56
57use std::{
58    ffi::OsString,
59    io::{Read, Write},
60    process::ExitCode,
61};
62
63use clap::ValueEnum;
64use serde::Serialize;
65
66use crate::command_result::CommandSuccess;
67use crate::{
68    error::{AppError, ErrorCode},
69    exit_code::StableExitCode,
70};
71
72pub(crate) use raw::render_human_response as render_api_response_human;
73#[cfg(test)]
74pub(crate) use raw::structured_api_response;
75
76#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
77#[value(rename_all = "lower")]
78pub enum OutputFormat {
79    #[default]
80    Human,
81    Json,
82    Raw,
83}
84
85impl OutputFormat {
86    pub fn detect_from_args(args: &[OsString]) -> Self {
87        let mut format = Self::Human;
88        let mut command = crate::command();
89        command.build();
90        let mut current = &command;
91        let mut iter = args.iter().skip(1).peekable();
92        while let Some(argument) = iter.next() {
93            let Some(arg) = argument.to_str() else {
94                continue;
95            };
96            if arg == "--" {
97                break;
98            }
99            if arg == "--output" {
100                format = match iter.next().and_then(|value| value.to_str()) {
101                    Some("json") => Self::Json,
102                    Some("raw") => Self::Raw,
103                    _ => Self::Human,
104                };
105            } else if let Some(value) = arg.strip_prefix("--output=") {
106                format = match value {
107                    "json" => Self::Json,
108                    "raw" => Self::Raw,
109                    _ => Self::Human,
110                };
111            } else if let Some(subcommand) = current.find_subcommand(arg) {
112                current = subcommand;
113            } else if let Some(option) = arg.strip_prefix("--").and_then(|name| {
114                current
115                    .get_arguments()
116                    .find(|option| option.get_long() == Some(name))
117            }) {
118                // Hyphen-prefixed values (for example user cards --from) are
119                // data even when they happen to look like an output option.
120                if option
121                    .get_num_args()
122                    .is_some_and(|arity| arity.takes_values())
123                    && iter.peek().is_some_and(|value| {
124                        option.is_allow_hyphen_values_set()
125                            || !value.to_string_lossy().starts_with('-')
126                    })
127                {
128                    iter.next();
129                }
130            }
131        }
132        format
133    }
134}
135
136#[derive(Serialize)]
137struct SuccessEnvelope<'a, T> {
138    ok: bool,
139    data: &'a T,
140}
141
142#[derive(Serialize)]
143struct ErrorEnvelope<'a> {
144    ok: bool,
145    error: ErrorBody<'a>,
146}
147
148#[derive(Serialize)]
149struct ErrorBody<'a> {
150    code: &'static str,
151    message: &'a str,
152    details: &'a crate::error::ErrorDetails,
153}
154
155pub fn render_success(format: OutputFormat, success: &CommandSuccess) -> String {
156    match (format, success) {
157        (OutputFormat::Human, CommandSuccess::SkillList(data)) => skills::list(data),
158        (OutputFormat::Json, CommandSuccess::SkillList(data)) => render_json_success(data),
159        (OutputFormat::Human, CommandSuccess::SkillShown(data)) => {
160            data.content.trim_end_matches(['\r', '\n']).to_owned()
161        }
162        (OutputFormat::Json, CommandSuccess::SkillShown(data)) => render_json_success(data),
163        (OutputFormat::Human, CommandSuccess::AuthPreflight(data)) => {
164            formatting::render_object(data)
165        }
166        (OutputFormat::Json, CommandSuccess::AuthPreflight(data)) => render_json_success(data),
167        (OutputFormat::Human, CommandSuccess::SchemaUpgradeStatus(data)) => {
168            formatting::render_object(data)
169        }
170        (OutputFormat::Json, CommandSuccess::SchemaUpgradeStatus(data)) => {
171            render_json_success(data)
172        }
173        (OutputFormat::Human, CommandSuccess::BoardAttachments(data)) => {
174            formatting::render_object(data)
175        }
176        (OutputFormat::Json, CommandSuccess::BoardAttachments(data)) => render_json_success(data),
177        (OutputFormat::Human, CommandSuccess::BoardImported(data)) => imports::board(data),
178        (OutputFormat::Json, CommandSuccess::BoardImported(data)) => render_json_success(data),
179        (OutputFormat::Human, CommandSuccess::TrelloImported(data)) => imports::trello(data),
180        (OutputFormat::Json, CommandSuccess::TrelloImported(data)) => render_json_success(data),
181        (OutputFormat::Human, CommandSuccess::ZipImported(data)) => imports::zip(data),
182        (OutputFormat::Json, CommandSuccess::ZipImported(data)) => render_json_success(data),
183        (OutputFormat::Human, CommandSuccess::IcsImported(data)) => imports::ics(data),
184        (OutputFormat::Json, CommandSuccess::IcsImported(data)) => render_json_success(data),
185        (OutputFormat::Human, CommandSuccess::Exported(data)) => formatting::render_object(data),
186        (OutputFormat::Json, CommandSuccess::Exported(data)) => render_json_success(data),
187        (OutputFormat::Human, CommandSuccess::AdminDomains(data)) => {
188            formatting::render_object(data)
189        }
190        (OutputFormat::Json, CommandSuccess::AdminDomains(data)) => render_json_success(data),
191        (OutputFormat::Human, CommandSuccess::AdminOrganizations(data)) => {
192            formatting::render_object(data)
193        }
194        (OutputFormat::Json, CommandSuccess::AdminOrganizations(data)) => render_json_success(data),
195        (OutputFormat::Human, CommandSuccess::AdminTeams(data)) => formatting::render_object(data),
196        (OutputFormat::Json, CommandSuccess::AdminTeams(data)) => render_json_success(data),
197        (OutputFormat::Human, CommandSuccess::AdminOrganizationUpdated(data)) => {
198            formatting::render_object(data)
199        }
200        (OutputFormat::Json, CommandSuccess::AdminOrganizationUpdated(data)) => {
201            render_json_success(data)
202        }
203        (OutputFormat::Human, CommandSuccess::AdminTeamUpdated(data)) => {
204            formatting::render_object(data)
205        }
206        (OutputFormat::Json, CommandSuccess::AdminTeamUpdated(data)) => render_json_success(data),
207        (OutputFormat::Human, CommandSuccess::AdminOrganizationFeaturesUpdatedAll(data)) => {
208            formatting::render_object(data)
209        }
210        (OutputFormat::Json, CommandSuccess::AdminOrganizationFeaturesUpdatedAll(data)) => {
211            render_json_success(data)
212        }
213        (OutputFormat::Human, CommandSuccess::AdminTeamFeaturesUpdatedAll(data)) => {
214            formatting::render_object(data)
215        }
216        (OutputFormat::Json, CommandSuccess::AdminTeamFeaturesUpdatedAll(data)) => {
217            render_json_success(data)
218        }
219        (OutputFormat::Human, CommandSuccess::GlobalSettings(data)) => {
220            formatting::render_object(data)
221        }
222        (OutputFormat::Json, CommandSuccess::GlobalSettings(data)) => render_json_success(data),
223        (OutputFormat::Human, CommandSuccess::GlobalSettingsUpdated(data)) => {
224            formatting::render_object(data)
225        }
226        (OutputFormat::Json, CommandSuccess::GlobalSettingsUpdated(data)) => {
227            render_json_success(data)
228        }
229        (OutputFormat::Human, CommandSuccess::AttachmentSettings(data)) => {
230            formatting::render_object(data)
231        }
232        (OutputFormat::Json, CommandSuccess::AttachmentSettings(data)) => render_json_success(data),
233        (OutputFormat::Human, CommandSuccess::AttachmentSettingsUpdated(data)) => {
234            formatting::render_object(data)
235        }
236        (OutputFormat::Json, CommandSuccess::AttachmentSettingsUpdated(data)) => {
237            render_json_success(data)
238        }
239
240        (OutputFormat::Human, CommandSuccess::BoardCardSettings(data)) => {
241            formatting::render_object(data)
242        }
243        (OutputFormat::Json, CommandSuccess::BoardCardSettings(data)) => render_json_success(data),
244        (OutputFormat::Human, CommandSuccess::BoardCardSettingsUpdated(data)) => {
245            formatting::render_object(data)
246        }
247        (OutputFormat::Json, CommandSuccess::BoardCardSettingsUpdated(data)) => {
248            render_json_success(data)
249        }
250        (OutputFormat::Human, CommandSuccess::BoardDomains(data)) => {
251            formatting::render_object(data)
252        }
253        (OutputFormat::Json, CommandSuccess::BoardDomains(data)) => render_json_success(data),
254        (OutputFormat::Human, CommandSuccess::BoardDomainChanged(data)) => format!(
255            "Board domains updated:\n{}",
256            formatting::render_object(data)
257        ),
258        (OutputFormat::Json, CommandSuccess::BoardDomainChanged(data)) => render_json_success(data),
259        (OutputFormat::Human, CommandSuccess::RuleCollection(data)) => rules::collection(data),
260        (OutputFormat::Json, CommandSuccess::RuleCollection(data)) => render_json_success(data),
261        (OutputFormat::Human, CommandSuccess::RuleShown(data)) => rules::detail(data),
262        (OutputFormat::Json, CommandSuccess::RuleShown(data)) => render_json_success(data),
263        (OutputFormat::Human, CommandSuccess::RuleCreated(data)) => rules::created(data),
264        (OutputFormat::Json, CommandSuccess::RuleCreated(data)) => render_json_success(data),
265        (OutputFormat::Human, CommandSuccess::RuleUpdated(data)) => rules::updated(data),
266        (OutputFormat::Json, CommandSuccess::RuleUpdated(data)) => render_json_success(data),
267        (OutputFormat::Human, CommandSuccess::RuleDeleted(data)) => rules::deleted(data),
268        (OutputFormat::Json, CommandSuccess::RuleDeleted(data)) => render_json_success(data),
269        (OutputFormat::Human, CommandSuccess::IntegrationCollection(data)) => {
270            integrations::collection(data)
271        }
272        (OutputFormat::Json, CommandSuccess::IntegrationCollection(data)) => {
273            render_json_success(data)
274        }
275        (OutputFormat::Human, CommandSuccess::IntegrationShown(data)) => integrations::detail(data),
276        (OutputFormat::Json, CommandSuccess::IntegrationShown(data)) => render_json_success(data),
277        (OutputFormat::Human, CommandSuccess::IntegrationCreated(data)) => {
278            integrations::created(data)
279        }
280        (OutputFormat::Json, CommandSuccess::IntegrationCreated(data)) => render_json_success(data),
281        (OutputFormat::Human, CommandSuccess::IntegrationUpdated(data)) => {
282            integrations::updated(data)
283        }
284        (OutputFormat::Json, CommandSuccess::IntegrationUpdated(data)) => render_json_success(data),
285        (OutputFormat::Human, CommandSuccess::IntegrationDeleted(data)) => {
286            integrations::deleted(data)
287        }
288        (OutputFormat::Json, CommandSuccess::IntegrationDeleted(data)) => render_json_success(data),
289        (OutputFormat::Human, CommandSuccess::IntegrationActivitiesChanged(data)) => {
290            integrations::activities(data)
291        }
292        (OutputFormat::Json, CommandSuccess::IntegrationActivitiesChanged(data)) => {
293            render_json_success(data)
294        }
295        (OutputFormat::Human, CommandSuccess::BoardCopied(data)) => {
296            format!(
297                "Board copied:\nWekan v11.06 omits attachments.\n{}",
298                formatting::render_object(data)
299            )
300        }
301        (OutputFormat::Json, CommandSuccess::BoardCopied(data)) => render_json_success(data),
302        (OutputFormat::Human, CommandSuccess::CardCopied(data)) => {
303            format!(
304                "Card copied:\nWekan v11.06 omits attachments.\n{}",
305                formatting::render_object(data)
306            )
307        }
308        (OutputFormat::Json, CommandSuccess::CardCopied(data)) => render_json_success(data),
309        (OutputFormat::Human, CommandSuccess::ListCopied(data)) => {
310            format!(
311                "List copied:\nWekan v11.06 omits attachments.\n{}",
312                formatting::render_object(data)
313            )
314        }
315        (OutputFormat::Human, CommandSuccess::ListMoved(data)) => {
316            format!(
317                "{}:\n{}",
318                if data.source_board_id == data.target_board_id {
319                    "List moved"
320                } else {
321                    "Cross-board list move request accepted (source list retained; position ignored)"
322                },
323                formatting::render_object(data)
324            )
325        }
326        (
327            OutputFormat::Json,
328            CommandSuccess::ListCopied(data) | CommandSuccess::ListMoved(data),
329        ) => render_json_success(data),
330        (OutputFormat::Human, CommandSuccess::SwimlaneCopied(data)) => {
331            format!(
332                "Swimlane copied:\nWekan v11.06 omits attachments.\n{}",
333                formatting::render_object(data)
334            )
335        }
336        (OutputFormat::Human, CommandSuccess::SwimlaneMoved(data)) => {
337            format!("Swimlane moved:\n{}", formatting::render_object(data))
338        }
339        (
340            OutputFormat::Json,
341            CommandSuccess::SwimlaneCopied(data) | CommandSuccess::SwimlaneMoved(data),
342        ) => render_json_success(data),
343        (OutputFormat::Human, CommandSuccess::CardCount(data)) => cards::render_card_count(data),
344        (OutputFormat::Json, CommandSuccess::CardCount(data)) => render_json_success(data),
345        (OutputFormat::Human, CommandSuccess::SwimlaneCards(data)) => {
346            cards::render_swimlane_cards(data)
347        }
348        (OutputFormat::Json, CommandSuccess::SwimlaneCards(data)) => render_json_success(data),
349        (OutputFormat::Human, CommandSuccess::CardBatch(data)) => cards::render_batch(data),
350        (OutputFormat::Json, CommandSuccess::CardBatch(data)) => match data {
351            crate::command_result::CardBatchResult::CardBulkCreate(data) => {
352                render_json_success(data)
353            }
354            crate::command_result::CardBatchResult::CardBulkDelete(data) => {
355                render_json_success(data)
356            }
357            crate::command_result::CardBatchResult::CardBulkLabels(data) => {
358                render_json_success(data)
359            }
360        },
361        (OutputFormat::Human, CommandSuccess::BoardLabels(data)) => formatting::render_object(data),
362        (OutputFormat::Json, CommandSuccess::BoardLabels(data)) => render_json_success(data),
363        (OutputFormat::Human, CommandSuccess::BoardLabelAdded(data)) => {
364            boards::render_label_added(data)
365        }
366        (OutputFormat::Json, CommandSuccess::BoardLabelAdded(data)) => render_json_success(data),
367        (OutputFormat::Human, CommandSuccess::CardLabels(data)) => formatting::render_object(data),
368        (OutputFormat::Json, CommandSuccess::CardLabels(data)) => render_json_success(data),
369        (OutputFormat::Human, CommandSuccess::BoardMembers(data)) => {
370            formatting::render_object(data)
371        }
372        (OutputFormat::Json, CommandSuccess::BoardMembers(data)) => render_json_success(data),
373        (OutputFormat::Human, CommandSuccess::BoardMemberChanged(data)) => {
374            let action = match data.action {
375                crate::command_result::MembershipAction::Add => "Addition",
376                crate::command_result::MembershipAction::Remove => "Removal",
377            };
378            format!(
379                "Board member {action} request accepted:\n{}",
380                formatting::render_object(data)
381            )
382        }
383        (OutputFormat::Json, CommandSuccess::BoardMemberChanged(data)) => render_json_success(data),
384        (OutputFormat::Human, CommandSuccess::BoardMemberUpdated(data)) => {
385            formatting::render_object(data)
386        }
387        (OutputFormat::Json, CommandSuccess::BoardMemberUpdated(data)) => render_json_success(data),
388        (OutputFormat::Human, CommandSuccess::CardMembers(data)) => formatting::render_object(data),
389        (OutputFormat::Json, CommandSuccess::CardMembers(data)) => render_json_success(data),
390        (OutputFormat::Human, CommandSuccess::CardAssignees(data)) => {
391            formatting::render_object(data)
392        }
393        (OutputFormat::Json, CommandSuccess::CardAssignees(data)) => render_json_success(data),
394        (OutputFormat::Human, CommandSuccess::CardMembershipChanged(data)) => {
395            formatting::render_object(data)
396        }
397        (OutputFormat::Json, CommandSuccess::CardMembershipChanged(data)) => {
398            render_json_success(data)
399        }
400        (OutputFormat::Human, CommandSuccess::CardDependencies(data)) => {
401            dependencies::card_collection(data)
402        }
403        (OutputFormat::Json, CommandSuccess::CardDependencies(data)) => render_json_success(data),
404        (OutputFormat::Human, CommandSuccess::BoardDependencies(data)) => {
405            dependencies::board_collection(data)
406        }
407        (OutputFormat::Json, CommandSuccess::BoardDependencies(data)) => render_json_success(data),
408        (OutputFormat::Human, CommandSuccess::DependencyCreated(data)) => {
409            dependencies::created(data)
410        }
411        (OutputFormat::Json, CommandSuccess::DependencyCreated(data)) => render_json_success(data),
412        (OutputFormat::Human, CommandSuccess::DependencyUpdated(data)) => {
413            dependencies::updated(data)
414        }
415        (OutputFormat::Json, CommandSuccess::DependencyUpdated(data)) => render_json_success(data),
416        (OutputFormat::Human, CommandSuccess::DependencyDeleted(data)) => {
417            dependencies::deleted(data)
418        }
419        (OutputFormat::Json, CommandSuccess::DependencyDeleted(data)) => render_json_success(data),
420        (OutputFormat::Human, CommandSuccess::AttachmentList(data)) => attachments::list(data),
421        (OutputFormat::Json, CommandSuccess::AttachmentList(data)) => render_json_success(data),
422        (OutputFormat::Human, CommandSuccess::AttachmentShown(data)) => attachments::detail(data),
423        (OutputFormat::Json, CommandSuccess::AttachmentShown(data)) => render_json_success(data),
424        (OutputFormat::Human, CommandSuccess::AttachmentUploaded(data)) => {
425            format!("Attachment uploaded:\n{}", attachments::detail(data))
426        }
427        (OutputFormat::Json, CommandSuccess::AttachmentUploaded(data)) => render_json_success(data),
428        (OutputFormat::Human, CommandSuccess::BackgroundUploaded(data)) => {
429            format!("Board background uploaded:\n{}", attachments::detail(data))
430        }
431        (OutputFormat::Json, CommandSuccess::BackgroundUploaded(data)) => render_json_success(data),
432        (OutputFormat::Human, CommandSuccess::AttachmentCopied(data)) => {
433            format!("Attachment copied:\n{}", attachments::detail(data))
434        }
435        (OutputFormat::Json, CommandSuccess::AttachmentCopied(data)) => render_json_success(data),
436        (OutputFormat::Human, CommandSuccess::AttachmentMoved(data)) => {
437            format!("Attachment moved:\n{}", attachments::detail(data))
438        }
439        (OutputFormat::Json, CommandSuccess::AttachmentMoved(data)) => render_json_success(data),
440        (OutputFormat::Human, CommandSuccess::AttachmentDeleted(data)) => {
441            format!("Attachment deleted:\n{}", attachments::detail(data))
442        }
443        (OutputFormat::Json, CommandSuccess::AttachmentDeleted(data)) => render_json_success(data),
444        (OutputFormat::Human, CommandSuccess::AttachmentDownloaded(data)) => {
445            attachments::detail(data)
446        }
447        (OutputFormat::Json, CommandSuccess::AttachmentDownloaded(data)) => {
448            render_json_success(data)
449        }
450        (_, CommandSuccess::ApiResponse(_)) => {
451            panic!("streaming API responses must be consumed by output::write_success")
452        }
453        (OutputFormat::Raw, CommandSuccess::Cancelled(_)) => String::new(),
454        (OutputFormat::Raw, _) => panic!("raw results must be consumed by output::write_success"),
455        (OutputFormat::Human, CommandSuccess::Cancelled(_)) => {
456            "Cancelled; no changes made.".to_owned()
457        }
458        (OutputFormat::Json, CommandSuccess::Cancelled(data)) => render_json_success(data),
459        (OutputFormat::Human, CommandSuccess::Registration(data)) => {
460            render_auth_success("Registered user", data)
461        }
462        (OutputFormat::Json, CommandSuccess::Registration(data)) => render_json_success(data),
463        (OutputFormat::Human, CommandSuccess::Login(data)) => {
464            render_auth_success("Logged in user", data)
465        }
466        (OutputFormat::Json, CommandSuccess::Login(data)) => render_json_success(data),
467        (OutputFormat::Human, CommandSuccess::Logout(data)) => render_logout(data),
468        (OutputFormat::Json, CommandSuccess::Logout(data)) => render_json_success(data),
469        (OutputFormat::Human, CommandSuccess::AuthStatus(data)) => render_auth_status(data),
470        (OutputFormat::Json, CommandSuccess::AuthStatus(data)) => render_json_success(data),
471        (OutputFormat::Human, CommandSuccess::UserCurrent(data))
472        | (OutputFormat::Human, CommandSuccess::UserShown(data))
473        | (OutputFormat::Human, CommandSuccess::UserUpdated(data)) => render_user_detail(data),
474        (OutputFormat::Json, CommandSuccess::UserCurrent(data))
475        | (OutputFormat::Json, CommandSuccess::UserShown(data))
476        | (OutputFormat::Json, CommandSuccess::UserUpdated(data)) => render_json_success(data),
477        (OutputFormat::Human, CommandSuccess::UserList(data)) => render_user_list(data),
478        (OutputFormat::Json, CommandSuccess::UserList(data)) => render_json_success(data),
479        (OutputFormat::Human, CommandSuccess::UserCards(data)) => render_user_cards(data),
480        (OutputFormat::Json, CommandSuccess::UserCards(data)) => render_json_success(data),
481        (OutputFormat::Human, CommandSuccess::UserCreated(data)) => render_user_created(data),
482        (OutputFormat::Json, CommandSuccess::UserCreated(data)) => render_json_success(data),
483        (OutputFormat::Human, CommandSuccess::UserBoards(data)) => render_user_boards(data),
484        (OutputFormat::Json, CommandSuccess::UserBoards(data)) => render_json_success(data),
485        (OutputFormat::Human, CommandSuccess::UserOwnershipTaken(data)) => {
486            render_user_ownership(data)
487        }
488        (OutputFormat::Json, CommandSuccess::UserOwnershipTaken(data)) => render_json_success(data),
489        (OutputFormat::Human, CommandSuccess::UserLoginChanged(data)) => {
490            render_user_login_change(data)
491        }
492        (OutputFormat::Json, CommandSuccess::UserLoginChanged(data)) => render_json_success(data),
493        (OutputFormat::Human, CommandSuccess::UserDeleted(data)) => render_user_deleted(data),
494        (OutputFormat::Json, CommandSuccess::UserDeleted(data)) => render_json_success(data),
495        (OutputFormat::Human, CommandSuccess::UserTokenCreated(data)) => {
496            users::render_token_created(data)
497        }
498        (OutputFormat::Json, CommandSuccess::UserTokenCreated(data)) => render_json_success(data),
499        (OutputFormat::Human, CommandSuccess::UserTokenDeleted(data)) => {
500            users::render_token_deleted(data)
501        }
502        (OutputFormat::Json, CommandSuccess::UserTokenDeleted(data)) => render_json_success(data),
503        (OutputFormat::Human, CommandSuccess::BoardList(data)) => render_board_list(data),
504        (OutputFormat::Json, CommandSuccess::BoardList(data)) => render_json_success(data),
505        (OutputFormat::Human, CommandSuccess::BoardCount(data)) => render_board_count(data),
506        (OutputFormat::Json, CommandSuccess::BoardCount(data)) => render_json_success(data),
507        (OutputFormat::Human, CommandSuccess::BoardShown(data)) => render_board_detail(data),
508        (OutputFormat::Json, CommandSuccess::BoardShown(data)) => render_json_success(data),
509        (OutputFormat::Human, CommandSuccess::BoardCreated(data)) => render_board_created(data),
510        (OutputFormat::Json, CommandSuccess::BoardCreated(data)) => render_json_success(data),
511        (OutputFormat::Human, CommandSuccess::BoardRenamed(data)) => render_board_renamed(data),
512        (OutputFormat::Json, CommandSuccess::BoardRenamed(data)) => render_json_success(data),
513        (OutputFormat::Human, CommandSuccess::BoardDeleted(data)) => render_board_deleted(data),
514        (OutputFormat::Json, CommandSuccess::BoardDeleted(data)) => render_json_success(data),
515        (OutputFormat::Human, CommandSuccess::ListCollection(data)) => render_list_collection(data),
516        (OutputFormat::Json, CommandSuccess::ListCollection(data)) => render_json_success(data),
517        (OutputFormat::Human, CommandSuccess::ListShown(data)) => render_list_detail(data),
518        (OutputFormat::Json, CommandSuccess::ListShown(data)) => render_json_success(data),
519        (OutputFormat::Human, CommandSuccess::ListCreated(data)) => render_list_created(data),
520        (OutputFormat::Json, CommandSuccess::ListCreated(data)) => render_json_success(data),
521        (OutputFormat::Human, CommandSuccess::ListUpdated(data)) => render_list_updated(data),
522        (OutputFormat::Json, CommandSuccess::ListUpdated(data)) => render_json_success(data),
523        (OutputFormat::Human, CommandSuccess::ListDeleted(data)) => render_list_deleted(data),
524        (OutputFormat::Json, CommandSuccess::ListDeleted(data)) => render_json_success(data),
525        (OutputFormat::Human, CommandSuccess::CardCollection(data)) => render_card_collection(data),
526        (OutputFormat::Json, CommandSuccess::CardCollection(data)) => render_json_success(data),
527        (OutputFormat::Human, CommandSuccess::CardShown(data)) => render_card_detail(data),
528        (OutputFormat::Json, CommandSuccess::CardShown(data)) => render_json_success(data),
529        (OutputFormat::Human, CommandSuccess::CardCreated(data)) => render_card_created(data),
530        (OutputFormat::Json, CommandSuccess::CardCreated(data)) => render_json_success(data),
531        (OutputFormat::Human, CommandSuccess::CardUpdated(data)) => render_card_updated(data),
532        (OutputFormat::Json, CommandSuccess::CardUpdated(data)) => render_json_success(data),
533        (OutputFormat::Human, CommandSuccess::CardArchived(data)) => render_card_archived(data),
534        (OutputFormat::Json, CommandSuccess::CardArchived(data)) => render_json_success(data),
535        (OutputFormat::Human, CommandSuccess::CardRestored(data)) => render_card_restored(data),
536        (OutputFormat::Json, CommandSuccess::CardRestored(data)) => render_json_success(data),
537        (OutputFormat::Human, CommandSuccess::CardDeleted(data)) => render_card_deleted(data),
538        (OutputFormat::Json, CommandSuccess::CardDeleted(data)) => render_json_success(data),
539        (OutputFormat::Human, CommandSuccess::ChecklistCollection(data)) => {
540            render_checklist_collection(data)
541        }
542        (OutputFormat::Json, CommandSuccess::ChecklistCollection(data)) => {
543            render_json_success(data)
544        }
545        (OutputFormat::Human, CommandSuccess::ChecklistShown(data)) => {
546            render_checklist_detail(data)
547        }
548        (OutputFormat::Json, CommandSuccess::ChecklistShown(data)) => render_json_success(data),
549        (OutputFormat::Human, CommandSuccess::ChecklistCreated(data)) => {
550            render_checklist_created(data)
551        }
552        (OutputFormat::Json, CommandSuccess::ChecklistCreated(data)) => render_json_success(data),
553        (OutputFormat::Human, CommandSuccess::ChecklistDeleted(data)) => {
554            render_checklist_deleted(data)
555        }
556        (OutputFormat::Json, CommandSuccess::ChecklistDeleted(data)) => render_json_success(data),
557        (OutputFormat::Human, CommandSuccess::ChecklistItemShown(data)) => {
558            render_checklist_item_detail(data)
559        }
560        (OutputFormat::Json, CommandSuccess::ChecklistItemShown(data)) => render_json_success(data),
561        (OutputFormat::Human, CommandSuccess::ChecklistItemCreated(data)) => {
562            render_checklist_item_created(data)
563        }
564        (OutputFormat::Json, CommandSuccess::ChecklistItemCreated(data)) => {
565            render_json_success(data)
566        }
567        (OutputFormat::Human, CommandSuccess::ChecklistItemUpdated(data)) => {
568            render_checklist_item_updated(data)
569        }
570        (OutputFormat::Json, CommandSuccess::ChecklistItemUpdated(data)) => {
571            render_json_success(data)
572        }
573        (OutputFormat::Human, CommandSuccess::ChecklistItemDeleted(data)) => {
574            render_checklist_item_deleted(data)
575        }
576        (OutputFormat::Json, CommandSuccess::ChecklistItemDeleted(data)) => {
577            render_json_success(data)
578        }
579        (OutputFormat::Human, CommandSuccess::CustomFieldCollection(data)) => {
580            custom_fields::collection(data)
581        }
582        (OutputFormat::Json, CommandSuccess::CustomFieldCollection(data)) => {
583            render_json_success(data)
584        }
585        (OutputFormat::Human, CommandSuccess::CustomFieldShown(data)) => {
586            custom_fields::detail(data)
587        }
588        (OutputFormat::Json, CommandSuccess::CustomFieldShown(data)) => render_json_success(data),
589        (OutputFormat::Human, CommandSuccess::CustomFieldCreated(data)) => {
590            custom_fields::created(data)
591        }
592        (OutputFormat::Json, CommandSuccess::CustomFieldCreated(data)) => render_json_success(data),
593        (OutputFormat::Human, CommandSuccess::CustomFieldUpdated(data)) => {
594            custom_fields::updated(data)
595        }
596        (OutputFormat::Json, CommandSuccess::CustomFieldUpdated(data)) => render_json_success(data),
597        (OutputFormat::Human, CommandSuccess::CustomFieldDeleted(data)) => {
598            custom_fields::deleted(data)
599        }
600        (OutputFormat::Json, CommandSuccess::CustomFieldDeleted(data)) => render_json_success(data),
601        (OutputFormat::Human, CommandSuccess::CustomFieldItemsCreated(data)) => {
602            custom_field_items::created(data)
603        }
604        (OutputFormat::Json, CommandSuccess::CustomFieldItemsCreated(data)) => {
605            render_json_success(data)
606        }
607        (OutputFormat::Human, CommandSuccess::CustomFieldItemUpdated(data)) => {
608            custom_field_items::updated(data)
609        }
610        (OutputFormat::Json, CommandSuccess::CustomFieldItemUpdated(data)) => {
611            render_json_success(data)
612        }
613        (OutputFormat::Human, CommandSuccess::CustomFieldItemDeleted(data)) => {
614            custom_field_items::deleted(data)
615        }
616        (OutputFormat::Json, CommandSuccess::CustomFieldItemDeleted(data)) => {
617            render_json_success(data)
618        }
619        (OutputFormat::Human, CommandSuccess::CardCustomFieldSet(data)) => {
620            cards::render_card_custom_field_set(data)
621        }
622        (OutputFormat::Json, CommandSuccess::CardCustomFieldSet(data)) => render_json_success(data),
623        (OutputFormat::Human, CommandSuccess::CardCustomFieldQuery(data)) => {
624            cards::render_card_custom_field_query(data)
625        }
626        (OutputFormat::Json, CommandSuccess::CardCustomFieldQuery(data)) => {
627            render_json_success(data)
628        }
629        (OutputFormat::Human, CommandSuccess::CommentCollection(data)) => {
630            render_comment_collection(data)
631        }
632        (OutputFormat::Json, CommandSuccess::CommentCollection(data)) => render_json_success(data),
633        (OutputFormat::Human, CommandSuccess::CommentShown(data)) => render_comment_detail(data),
634        (OutputFormat::Json, CommandSuccess::CommentShown(data)) => render_json_success(data),
635        (OutputFormat::Human, CommandSuccess::CommentCreated(data)) => render_comment_created(data),
636        (OutputFormat::Json, CommandSuccess::CommentCreated(data)) => render_json_success(data),
637        (OutputFormat::Human, CommandSuccess::CommentDeleted(data)) => render_comment_deleted(data),
638        (OutputFormat::Json, CommandSuccess::CommentDeleted(data)) => render_json_success(data),
639        (OutputFormat::Human, CommandSuccess::SwimlaneCollection(data)) => {
640            render_swimlane_collection(data)
641        }
642        (OutputFormat::Json, CommandSuccess::SwimlaneCollection(data)) => render_json_success(data),
643        (OutputFormat::Human, CommandSuccess::SwimlaneShown(data)) => render_swimlane_detail(data),
644        (OutputFormat::Json, CommandSuccess::SwimlaneShown(data)) => render_json_success(data),
645        (OutputFormat::Human, CommandSuccess::SwimlaneCreated(data)) => {
646            render_swimlane_created(data)
647        }
648        (OutputFormat::Json, CommandSuccess::SwimlaneCreated(data)) => render_json_success(data),
649        (OutputFormat::Human, CommandSuccess::SwimlaneUpdated(data)) => {
650            render_swimlane_updated(data)
651        }
652        (OutputFormat::Json, CommandSuccess::SwimlaneUpdated(data)) => render_json_success(data),
653        (OutputFormat::Human, CommandSuccess::SwimlaneDeleted(data)) => {
654            render_swimlane_deleted(data)
655        }
656        (OutputFormat::Json, CommandSuccess::SwimlaneDeleted(data)) => render_json_success(data),
657        (OutputFormat::Human, CommandSuccess::ProfileAdded(data)) => {
658            render_profile_item("Added profile", data)
659        }
660        (OutputFormat::Json, CommandSuccess::ProfileAdded(data))
661        | (OutputFormat::Json, CommandSuccess::ProfileShown(data))
662        | (OutputFormat::Json, CommandSuccess::ProfileUsed(data))
663        | (OutputFormat::Json, CommandSuccess::ProfileUpdated(data)) => {
664            serde_json::to_string(&SuccessEnvelope { ok: true, data })
665                .expect("profile success is always serializable")
666        }
667        (OutputFormat::Human, CommandSuccess::ProfileList(data)) => render_profile_list(data),
668        (OutputFormat::Json, CommandSuccess::ProfileList(data)) => {
669            serde_json::to_string(&SuccessEnvelope { ok: true, data })
670                .expect("profile list success is always serializable")
671        }
672        (OutputFormat::Human, CommandSuccess::ProfileShown(data)) => {
673            render_profile_item("Profile", data)
674        }
675        (OutputFormat::Human, CommandSuccess::ProfileUsed(data)) => {
676            render_profile_item("Using profile", data)
677        }
678        (OutputFormat::Human, CommandSuccess::ProfileUpdated(data)) => {
679            render_profile_item("Updated profile", data)
680        }
681        (OutputFormat::Human, CommandSuccess::ProfileRemoved(data)) => render_profile_removed(data),
682        (OutputFormat::Json, CommandSuccess::ProfileRemoved(data)) => {
683            serde_json::to_string(&SuccessEnvelope { ok: true, data })
684                .expect("profile removal success is always serializable")
685        }
686    }
687}
688
689pub async fn write_success(
690    format: OutputFormat,
691    success: CommandSuccess,
692    stdout: &mut dyn Write,
693    stderr: &mut dyn Write,
694) -> Result<ExitCode, AppError> {
695    if let CommandSuccess::ApiResponse(response) = success {
696        return raw::write_response(format, response, stdout, stderr).await;
697    }
698    if format == OutputFormat::Raw {
699        if let CommandSuccess::AttachmentDownloaded(data) = &success {
700            let bytes = data.bytes.as_deref().ok_or_else(|| {
701                AppError::invalid_input("raw attachment output requires --destination -")
702            })?;
703            stdout
704                .write_all(bytes)
705                .and_then(|()| stdout.flush())
706                .map_err(output_write_error)?;
707            return Ok(ExitCode::SUCCESS);
708        }
709    }
710    if format == OutputFormat::Raw {
711        if let CommandSuccess::Exported(data) = &success {
712            let staged = data.staged.as_ref().ok_or_else(|| {
713                AppError::invalid_input("raw export output requires --destination -")
714            })?;
715            let mut file = staged.0.reopen().map_err(export_read_error)?;
716            let mut buffer = [0; 64 * 1024];
717            loop {
718                let read = file.read(&mut buffer).map_err(export_read_error)?;
719                if read == 0 {
720                    break;
721                }
722                stdout
723                    .write_all(&buffer[..read])
724                    .map_err(output_write_error)?;
725            }
726            stdout.flush().map_err(output_write_error)?;
727            return Ok(ExitCode::SUCCESS);
728        }
729    }
730    let rendered = render_success(format, &success);
731    if !rendered.is_empty() {
732        let written = writeln!(stdout, "{rendered}").and_then(|()| {
733            if matches!(&success, CommandSuccess::AttachmentDownloaded(data) if data.file_saved)
734                || matches!(&success, CommandSuccess::Exported(data) if data.file_saved)
735            {
736                stdout.flush()
737            } else {
738                Ok(())
739            }
740        });
741        written.map_err(|e| {
742            let error = output_write_error(e);
743            if let CommandSuccess::Exported(data) = &success {
744                if data.file_saved {
745                    return error.with_details(crate::error::ErrorDetails {
746                        file_saved: Some(true),
747                        destination: Some(data.destination.clone()),
748                        ..crate::error::ErrorDetails::default()
749                    });
750                }
751            }
752            if let CommandSuccess::AttachmentDownloaded(data) = &success {
753                if data.file_saved {
754                    return error.with_details(crate::error::ErrorDetails {
755                        file_saved: Some(true),
756                        destination: Some(data.destination.clone()),
757                        ..crate::error::ErrorDetails::default()
758                    });
759                }
760            }
761            error
762        })?;
763    }
764    Ok(ExitCode::SUCCESS)
765}
766fn export_read_error(error: std::io::Error) -> AppError {
767    AppError::new(
768        ErrorCode::FileIoError,
769        format!(
770            "could not read the staged export file: {}",
771            crate::input::escape_terminal_text(&error.to_string())
772        ),
773        StableExitCode::Transport,
774    )
775}
776pub(crate) fn output_write_error(error: std::io::Error) -> AppError {
777    AppError::new(
778        ErrorCode::InternalError,
779        format!("could not write command output: {error}"),
780        StableExitCode::Transport,
781    )
782}
783
784pub(crate) fn render_json_success<T: Serialize>(data: &T) -> String {
785    serde_json::to_string(&SuccessEnvelope { ok: true, data })
786        .expect("command success is always serializable")
787}
788
789pub fn render_error(format: OutputFormat, error: &AppError) -> String {
790    match format {
791        OutputFormat::Human => {
792            let mut rendered = format!(
793                "Error [{}]: {}",
794                error.code().as_str(),
795                error.message().trim()
796            );
797            if let Some(batch) = &error.details().batch_result {
798                rendered.push('\n');
799                rendered.push_str(&cards::render_batch(batch));
800            }
801            if let Some(result) = &error.details().trello_import_result {
802                rendered.push('\n');
803                rendered.push_str(&imports::trello(result));
804            }
805            if let Some(ids) = &error.details().invalid_label_ids {
806                rendered.push_str(&format!(
807                    "\nInvalid label IDs: {}",
808                    formatting::escape_terminal_controls(&ids.join(", "))
809                ));
810            }
811            if let Some(response) = &error.details().response {
812                rendered.push('\n');
813                rendered.push_str(&render_api_response_human(response));
814            }
815            if error.details().file_saved == Some(true) {
816                if let Some(destination) = &error.details().destination {
817                    rendered.push_str(&format!(
818                        "\nDownload saved to `{}`; completion output failed.",
819                        formatting::escape_terminal_controls(destination)
820                    ));
821                }
822            }
823            rendered
824        }
825        OutputFormat::Json => serde_json::to_string(&ErrorEnvelope {
826            ok: false,
827            error: ErrorBody {
828                code: error.code().as_str(),
829                message: error.message(),
830                details: error.details(),
831            },
832        })
833        .expect("application errors are always serializable"),
834        OutputFormat::Raw => format!(
835            "Error [{}]: {}",
836            error.code().as_str(),
837            error.message().trim()
838        ),
839    }
840}
841
842#[cfg(test)]
843mod tests;