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