Skip to main content

wekan_cli/
output.rs

1mod raw;
2
3use std::{ffi::OsString, io::Write, process::ExitCode};
4
5use clap::ValueEnum;
6use serde::Serialize;
7
8use crate::command_result::{
9    AuthStatusSuccess, AuthSuccess, BoardCountSuccess, BoardCreateSuccess, BoardDeleteSuccess,
10    BoardDetail, BoardListScope, BoardListSuccess, BoardRenameSuccess, CardCollectionSuccess,
11    CardCreateSuccess, CardDeleteSuccess, CardDetail, CardUpdateSuccess, CommandSuccess,
12    CommentCollectionSuccess, CommentCreateSuccess, CommentDeleteSuccess, CommentDetail,
13    ListCollectionSuccess, ListCreateSuccess, ListDeleteSuccess, ListDetail, ListUpdateSuccess,
14    LogoutScope, LogoutSuccess, ProfileItem, ProfileListSuccess, ProfileRemoveSuccess,
15    SwimlaneCollectionSuccess, SwimlaneCreateSuccess, SwimlaneDeleteSuccess, SwimlaneDetail,
16    SwimlaneUpdateSuccess, UserBoardsSuccess, UserCardsSuccess, UserCreateSuccess,
17    UserDeleteSuccess, UserDetail, UserListSuccess, UserLoginChangeSuccess, UserOwnershipSuccess,
18};
19use crate::{
20    error::{AppError, ErrorCode},
21    exit_code::StableExitCode,
22};
23
24pub(crate) use raw::render_human_response as render_api_response_human;
25#[cfg(test)]
26pub(crate) use raw::structured_api_response;
27
28#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, ValueEnum)]
29#[value(rename_all = "lower")]
30pub enum OutputFormat {
31    #[default]
32    Human,
33    Json,
34    Raw,
35}
36
37impl OutputFormat {
38    pub fn detect_from_args(args: &[OsString]) -> Self {
39        let mut iter = args.iter().filter_map(|arg| arg.to_str());
40        while let Some(arg) = iter.next() {
41            if arg == "--output" {
42                return match iter.next() {
43                    Some("json") => Self::Json,
44                    Some("raw") => Self::Raw,
45                    _ => Self::Human,
46                };
47            }
48            if arg == "--output=json" {
49                return Self::Json;
50            }
51            if arg == "--output=raw" {
52                return Self::Raw;
53            }
54        }
55        Self::Human
56    }
57}
58
59#[derive(Serialize)]
60struct SuccessEnvelope<'a, T> {
61    ok: bool,
62    data: &'a T,
63}
64
65#[derive(Serialize)]
66struct ErrorEnvelope<'a> {
67    ok: bool,
68    error: ErrorBody<'a>,
69}
70
71#[derive(Serialize)]
72struct ErrorBody<'a> {
73    code: &'static str,
74    message: &'a str,
75    details: &'a crate::error::ErrorDetails,
76}
77
78pub fn render_success(format: OutputFormat, success: &CommandSuccess) -> String {
79    match (format, success) {
80        (_, CommandSuccess::ApiResponse(_)) => {
81            panic!("streaming API responses must be consumed by output::write_success")
82        }
83        (OutputFormat::Raw, CommandSuccess::Cancelled(_)) => String::new(),
84        (OutputFormat::Raw, _) => panic!("raw output is valid only for api request"),
85        (OutputFormat::Human, CommandSuccess::Cancelled(_)) => {
86            "Cancelled; no changes made.".to_owned()
87        }
88        (OutputFormat::Json, CommandSuccess::Cancelled(data)) => {
89            serde_json::to_string(&SuccessEnvelope { ok: true, data })
90                .expect("cancellation success is always serializable")
91        }
92        (OutputFormat::Human, CommandSuccess::Registration(data)) => {
93            render_auth_success("Registered user", data)
94        }
95        (OutputFormat::Json, CommandSuccess::Registration(data)) => {
96            serde_json::to_string(&SuccessEnvelope { ok: true, data })
97                .expect("registration success is always serializable")
98        }
99        (OutputFormat::Human, CommandSuccess::Login(data)) => {
100            render_auth_success("Logged in user", data)
101        }
102        (OutputFormat::Json, CommandSuccess::Login(data)) => {
103            serde_json::to_string(&SuccessEnvelope { ok: true, data })
104                .expect("login success is always serializable")
105        }
106        (OutputFormat::Human, CommandSuccess::Logout(data)) => render_logout(data),
107        (OutputFormat::Json, CommandSuccess::Logout(data)) => {
108            serde_json::to_string(&SuccessEnvelope { ok: true, data })
109                .expect("logout success is always serializable")
110        }
111        (OutputFormat::Human, CommandSuccess::AuthStatus(data)) => render_auth_status(data),
112        (OutputFormat::Json, CommandSuccess::AuthStatus(data)) => {
113            serde_json::to_string(&SuccessEnvelope { ok: true, data })
114                .expect("authentication status success is always serializable")
115        }
116        (OutputFormat::Human, CommandSuccess::UserCurrent(data))
117        | (OutputFormat::Human, CommandSuccess::UserShown(data)) => render_user_detail(data),
118        (OutputFormat::Json, CommandSuccess::UserCurrent(data))
119        | (OutputFormat::Json, CommandSuccess::UserShown(data)) => render_json_success(data),
120        (OutputFormat::Human, CommandSuccess::UserList(data)) => render_user_list(data),
121        (OutputFormat::Json, CommandSuccess::UserList(data)) => render_json_success(data),
122        (OutputFormat::Human, CommandSuccess::UserCards(data)) => render_user_cards(data),
123        (OutputFormat::Json, CommandSuccess::UserCards(data)) => render_json_success(data),
124        (OutputFormat::Human, CommandSuccess::UserCreated(data)) => render_user_created(data),
125        (OutputFormat::Json, CommandSuccess::UserCreated(data)) => render_json_success(data),
126        (OutputFormat::Human, CommandSuccess::UserBoards(data)) => render_user_boards(data),
127        (OutputFormat::Json, CommandSuccess::UserBoards(data)) => render_json_success(data),
128        (OutputFormat::Human, CommandSuccess::UserOwnershipTaken(data)) => {
129            render_user_ownership(data)
130        }
131        (OutputFormat::Json, CommandSuccess::UserOwnershipTaken(data)) => render_json_success(data),
132        (OutputFormat::Human, CommandSuccess::UserLoginChanged(data)) => {
133            render_user_login_change(data)
134        }
135        (OutputFormat::Json, CommandSuccess::UserLoginChanged(data)) => render_json_success(data),
136        (OutputFormat::Human, CommandSuccess::UserDeleted(data)) => render_user_deleted(data),
137        (OutputFormat::Json, CommandSuccess::UserDeleted(data)) => render_json_success(data),
138        (OutputFormat::Human, CommandSuccess::BoardList(data)) => render_board_list(data),
139        (OutputFormat::Json, CommandSuccess::BoardList(data)) => render_json_success(data),
140        (OutputFormat::Human, CommandSuccess::BoardCount(data)) => render_board_count(data),
141        (OutputFormat::Json, CommandSuccess::BoardCount(data)) => render_json_success(data),
142        (OutputFormat::Human, CommandSuccess::BoardShown(data)) => render_board_detail(data),
143        (OutputFormat::Json, CommandSuccess::BoardShown(data)) => render_json_success(data),
144        (OutputFormat::Human, CommandSuccess::BoardCreated(data)) => render_board_created(data),
145        (OutputFormat::Json, CommandSuccess::BoardCreated(data)) => render_json_success(data),
146        (OutputFormat::Human, CommandSuccess::BoardRenamed(data)) => render_board_renamed(data),
147        (OutputFormat::Json, CommandSuccess::BoardRenamed(data)) => render_json_success(data),
148        (OutputFormat::Human, CommandSuccess::BoardDeleted(data)) => render_board_deleted(data),
149        (OutputFormat::Json, CommandSuccess::BoardDeleted(data)) => render_json_success(data),
150        (OutputFormat::Human, CommandSuccess::ListCollection(data)) => render_list_collection(data),
151        (OutputFormat::Json, CommandSuccess::ListCollection(data)) => render_json_success(data),
152        (OutputFormat::Human, CommandSuccess::ListShown(data)) => render_list_detail(data),
153        (OutputFormat::Json, CommandSuccess::ListShown(data)) => render_json_success(data),
154        (OutputFormat::Human, CommandSuccess::ListCreated(data)) => render_list_created(data),
155        (OutputFormat::Json, CommandSuccess::ListCreated(data)) => render_json_success(data),
156        (OutputFormat::Human, CommandSuccess::ListUpdated(data)) => render_list_updated(data),
157        (OutputFormat::Json, CommandSuccess::ListUpdated(data)) => render_json_success(data),
158        (OutputFormat::Human, CommandSuccess::ListDeleted(data)) => render_list_deleted(data),
159        (OutputFormat::Json, CommandSuccess::ListDeleted(data)) => render_json_success(data),
160        (OutputFormat::Human, CommandSuccess::CardCollection(data)) => render_card_collection(data),
161        (OutputFormat::Json, CommandSuccess::CardCollection(data)) => render_json_success(data),
162        (OutputFormat::Human, CommandSuccess::CardShown(data)) => render_card_detail(data),
163        (OutputFormat::Json, CommandSuccess::CardShown(data)) => render_json_success(data),
164        (OutputFormat::Human, CommandSuccess::CardCreated(data)) => render_card_created(data),
165        (OutputFormat::Json, CommandSuccess::CardCreated(data)) => render_json_success(data),
166        (OutputFormat::Human, CommandSuccess::CardUpdated(data)) => render_card_updated(data),
167        (OutputFormat::Json, CommandSuccess::CardUpdated(data)) => render_json_success(data),
168        (OutputFormat::Human, CommandSuccess::CardDeleted(data)) => render_card_deleted(data),
169        (OutputFormat::Json, CommandSuccess::CardDeleted(data)) => render_json_success(data),
170        (OutputFormat::Human, CommandSuccess::CommentCollection(data)) => {
171            render_comment_collection(data)
172        }
173        (OutputFormat::Json, CommandSuccess::CommentCollection(data)) => render_json_success(data),
174        (OutputFormat::Human, CommandSuccess::CommentShown(data)) => render_comment_detail(data),
175        (OutputFormat::Json, CommandSuccess::CommentShown(data)) => render_json_success(data),
176        (OutputFormat::Human, CommandSuccess::CommentCreated(data)) => render_comment_created(data),
177        (OutputFormat::Json, CommandSuccess::CommentCreated(data)) => render_json_success(data),
178        (OutputFormat::Human, CommandSuccess::CommentDeleted(data)) => render_comment_deleted(data),
179        (OutputFormat::Json, CommandSuccess::CommentDeleted(data)) => render_json_success(data),
180        (OutputFormat::Human, CommandSuccess::SwimlaneCollection(data)) => {
181            render_swimlane_collection(data)
182        }
183        (OutputFormat::Json, CommandSuccess::SwimlaneCollection(data)) => render_json_success(data),
184        (OutputFormat::Human, CommandSuccess::SwimlaneShown(data)) => render_swimlane_detail(data),
185        (OutputFormat::Json, CommandSuccess::SwimlaneShown(data)) => render_json_success(data),
186        (OutputFormat::Human, CommandSuccess::SwimlaneCreated(data)) => {
187            render_swimlane_created(data)
188        }
189        (OutputFormat::Json, CommandSuccess::SwimlaneCreated(data)) => render_json_success(data),
190        (OutputFormat::Human, CommandSuccess::SwimlaneUpdated(data)) => {
191            render_swimlane_updated(data)
192        }
193        (OutputFormat::Json, CommandSuccess::SwimlaneUpdated(data)) => render_json_success(data),
194        (OutputFormat::Human, CommandSuccess::SwimlaneDeleted(data)) => {
195            render_swimlane_deleted(data)
196        }
197        (OutputFormat::Json, CommandSuccess::SwimlaneDeleted(data)) => render_json_success(data),
198        (OutputFormat::Human, CommandSuccess::ProfileAdded(data)) => {
199            render_profile_item("Added profile", data)
200        }
201        (OutputFormat::Json, CommandSuccess::ProfileAdded(data))
202        | (OutputFormat::Json, CommandSuccess::ProfileShown(data))
203        | (OutputFormat::Json, CommandSuccess::ProfileUsed(data))
204        | (OutputFormat::Json, CommandSuccess::ProfileUpdated(data)) => {
205            serde_json::to_string(&SuccessEnvelope { ok: true, data })
206                .expect("profile success is always serializable")
207        }
208        (OutputFormat::Human, CommandSuccess::ProfileList(data)) => render_profile_list(data),
209        (OutputFormat::Json, CommandSuccess::ProfileList(data)) => {
210            serde_json::to_string(&SuccessEnvelope { ok: true, data })
211                .expect("profile list success is always serializable")
212        }
213        (OutputFormat::Human, CommandSuccess::ProfileShown(data)) => {
214            render_profile_item("Profile", data)
215        }
216        (OutputFormat::Human, CommandSuccess::ProfileUsed(data)) => {
217            render_profile_item("Using profile", data)
218        }
219        (OutputFormat::Human, CommandSuccess::ProfileUpdated(data)) => {
220            render_profile_item("Updated profile", data)
221        }
222        (OutputFormat::Human, CommandSuccess::ProfileRemoved(data)) => render_profile_removed(data),
223        (OutputFormat::Json, CommandSuccess::ProfileRemoved(data)) => {
224            serde_json::to_string(&SuccessEnvelope { ok: true, data })
225                .expect("profile removal success is always serializable")
226        }
227    }
228}
229
230pub async fn write_success(
231    format: OutputFormat,
232    success: CommandSuccess,
233    stdout: &mut dyn Write,
234    stderr: &mut dyn Write,
235) -> Result<ExitCode, AppError> {
236    if let CommandSuccess::ApiResponse(response) = success {
237        return raw::write_response(format, response, stdout, stderr).await;
238    }
239    let rendered = render_success(format, &success);
240    if !rendered.is_empty() {
241        writeln!(stdout, "{rendered}").map_err(output_write_error)?;
242    }
243    Ok(ExitCode::SUCCESS)
244}
245pub(crate) fn output_write_error(error: std::io::Error) -> AppError {
246    AppError::new(
247        ErrorCode::InternalError,
248        format!("could not write command output: {error}"),
249        StableExitCode::Transport,
250    )
251}
252
253pub(crate) fn render_json_success<T: Serialize>(data: &T) -> String {
254    serde_json::to_string(&SuccessEnvelope { ok: true, data })
255        .expect("command success is always serializable")
256}
257
258fn render_user_detail(data: &UserDetail) -> String {
259    let mut lines = vec![format!(
260        "User ID: {}",
261        escape_terminal_controls(&data.user_id)
262    )];
263    push_optional(&mut lines, "Username", data.username.as_deref());
264    push_optional(&mut lines, "Full name", data.full_name.as_deref());
265    push_optional_bool(&mut lines, "Administrator", data.is_admin);
266    push_optional_bool(&mut lines, "Login disabled", data.login_disabled);
267    push_optional(
268        &mut lines,
269        "Authentication method",
270        data.authentication_method.as_deref(),
271    );
272    push_optional(&mut lines, "Created", data.created_at.as_deref());
273    push_optional(&mut lines, "Modified", data.modified_at.as_deref());
274    push_optional(
275        &mut lines,
276        "Last connection",
277        data.last_connection_date.as_deref(),
278    );
279    if !data.emails.is_empty() {
280        let emails = data
281            .emails
282            .iter()
283            .map(|email| {
284                let address =
285                    escape_terminal_controls(email.address.as_deref().unwrap_or("unknown address"));
286                let verified = match email.verified {
287                    Some(true) => "verified",
288                    Some(false) => "unverified",
289                    None => "verification unknown",
290                };
291                format!("{address} ({verified})")
292            })
293            .collect::<Vec<_>>()
294            .join(", ");
295        lines.push(format!("Emails: {emails}"));
296    }
297    if !data.organizations.is_empty() {
298        lines.push(format!("Organizations: {}", data.organizations.len()));
299    }
300    if !data.teams.is_empty() {
301        lines.push(format!("Teams: {}", data.teams.len()));
302    }
303    if !data.boards.is_empty() {
304        lines.push(format!("Board memberships: {}", data.boards.len()));
305    }
306    lines.join("\n")
307}
308
309fn push_optional(lines: &mut Vec<String>, label: &str, value: Option<&str>) {
310    if let Some(value) = value {
311        lines.push(format!("{label}: {}", escape_terminal_controls(value)));
312    }
313}
314
315fn push_optional_bool(lines: &mut Vec<String>, label: &str, value: Option<bool>) {
316    if let Some(value) = value {
317        lines.push(format!("{label}: {}", if value { "yes" } else { "no" }));
318    }
319}
320
321fn render_user_list(data: &UserListSuccess) -> String {
322    if data.users.is_empty() {
323        return "No users found.".to_owned();
324    }
325    let rows = data
326        .users
327        .iter()
328        .map(|user| {
329            (
330                escape_terminal_controls(&user.user_id),
331                escape_terminal_controls(user.username.as_deref().unwrap_or("<none>")),
332            )
333        })
334        .collect::<Vec<_>>();
335    two_column_table("ID", "USERNAME", &rows)
336}
337
338fn render_user_cards(data: &UserCardsSuccess) -> String {
339    if data.cards.is_empty() {
340        return "No matching cards found.".to_owned();
341    }
342    let mut lines = vec!["ID  TITLE  BOARD  LIST  DUE".to_owned()];
343    lines.extend(data.cards.iter().map(|card| {
344        format!(
345            "{}  {}  {}  {}  {}",
346            escape_terminal_controls(&card.card_id),
347            escape_terminal_controls(card.title.as_deref().unwrap_or("")),
348            escape_terminal_controls(card.board_id.as_deref().unwrap_or("")),
349            escape_terminal_controls(card.list_id.as_deref().unwrap_or("")),
350            escape_terminal_controls(card.due_at.as_deref().unwrap_or("")),
351        )
352    }));
353    lines.join("\n")
354}
355
356fn render_user_created(data: &UserCreateSuccess) -> String {
357    let mut lines = vec![format!(
358        "Created user {} ({})",
359        escape_terminal_controls(&data.username),
360        escape_terminal_controls(&data.email)
361    )];
362    if let Some(user_id) = &data.user_id {
363        lines.push(format!("User ID: {}", escape_terminal_controls(user_id)));
364    } else {
365        lines.push("User ID: unavailable".to_owned());
366    }
367    if data.warning.is_some() {
368        lines.push(
369            "Warning [user_id_unavailable_in_wekan_v11_06]: Wekan created the user but did not return its ID."
370                .to_owned(),
371        );
372    }
373    lines.join("\n")
374}
375
376fn render_user_boards(data: &UserBoardsSuccess) -> String {
377    if data.boards.is_empty() {
378        return format!(
379            "No active boards found for user {}.",
380            escape_terminal_controls(&data.user_id)
381        );
382    }
383    let rows = data
384        .boards
385        .iter()
386        .map(|board| {
387            (
388                escape_terminal_controls(&board.board_id),
389                escape_terminal_controls(&board.title),
390            )
391        })
392        .collect::<Vec<_>>();
393    two_column_table("ID", "TITLE", &rows)
394}
395
396fn render_user_ownership(data: &UserOwnershipSuccess) -> String {
397    let mut lines = vec![format!(
398        "Transferred {} board(s) from user {} to user {}.",
399        data.boards.len(),
400        escape_terminal_controls(&data.from_user_id),
401        escape_terminal_controls(&data.to_user_id)
402    )];
403    if !data.boards.is_empty() {
404        let rows = data
405            .boards
406            .iter()
407            .map(|board| {
408                (
409                    escape_terminal_controls(&board.board_id),
410                    escape_terminal_controls(&board.title),
411                )
412            })
413            .collect::<Vec<_>>();
414        lines.push(two_column_table("ID", "TITLE", &rows));
415    }
416    lines.join("\n")
417}
418
419fn render_user_login_change(data: &UserLoginChangeSuccess) -> String {
420    let action = match data.action {
421        crate::command_result::UserLoginAction::Disabled => "Set loginDisabled=true for",
422        crate::command_result::UserLoginAction::Enabled => "Cleared loginDisabled for",
423    };
424    format!(
425        "{action} user {}.",
426        escape_terminal_controls(&data.user.user_id)
427    )
428}
429
430fn render_user_deleted(data: &UserDeleteSuccess) -> String {
431    let mut lines = vec![format!(
432        "Deleted user {}.",
433        escape_terminal_controls(&data.user_id)
434    )];
435    if data.deleted_current_user {
436        lines.push(if data.local_credential_removed {
437            "Removed the matching local credential.".to_owned()
438        } else if data.credential_stored {
439            "The local credential changed concurrently and was preserved.".to_owned()
440        } else {
441            "No matching local credential remained.".to_owned()
442        });
443    }
444    lines.join("\n")
445}
446
447fn render_board_list(data: &BoardListSuccess) -> String {
448    if data.boards.is_empty() {
449        return match data.scope {
450            BoardListScope::Active => "No active boards found.".to_owned(),
451            BoardListScope::Public => "No public boards found.".to_owned(),
452        };
453    }
454    let rows = data
455        .boards
456        .iter()
457        .map(|board| {
458            (
459                escape_terminal_controls(&board.board_id),
460                escape_terminal_controls(&board.title),
461            )
462        })
463        .collect::<Vec<_>>();
464    two_column_table("ID", "TITLE", &rows)
465}
466
467fn render_board_count(data: &BoardCountSuccess) -> String {
468    format!(
469        "Private boards: {}\nPublic boards: {}",
470        data.private, data.public
471    )
472}
473
474fn render_board_created(data: &BoardCreateSuccess) -> String {
475    format!(
476        "Created board {}.\nDefault swimlane ID: {}",
477        escape_terminal_controls(&data.board_id),
478        escape_terminal_controls(&data.default_swimlane_id)
479    )
480}
481
482fn render_board_renamed(data: &BoardRenameSuccess) -> String {
483    format!(
484        "Renamed board {} to {}.",
485        escape_terminal_controls(&data.board_id),
486        escape_terminal_controls(&data.title)
487    )
488}
489
490fn render_board_deleted(data: &BoardDeleteSuccess) -> String {
491    format!(
492        "Deleted board {}.",
493        escape_terminal_controls(&data.board_id)
494    )
495}
496
497fn render_list_collection(data: &ListCollectionSuccess) -> String {
498    if data.lists.is_empty() {
499        return format!(
500            "No lists found on board {}.",
501            escape_terminal_controls(&data.board_id)
502        );
503    }
504    let mut lines = vec!["ID  TITLE  MODIFIED  CARDS MODIFIED".to_owned()];
505    lines.extend(data.lists.iter().map(|list| {
506        format!(
507            "{}  {}  {}  {}",
508            escape_terminal_controls(&list.list_id),
509            escape_terminal_controls(&list.title),
510            escape_terminal_controls(list.modified_at.as_deref().unwrap_or("")),
511            escape_terminal_controls(list.cards_modified_at.as_deref().unwrap_or("")),
512        )
513    }));
514    lines.join("\n")
515}
516
517fn render_list_detail(data: &ListDetail) -> String {
518    let value = serde_json::to_value(data).expect("list documents are always serializable");
519    let serde_json::Value::Object(mut fields) = value else {
520        unreachable!("list documents serialize as objects")
521    };
522    fields.remove("list_id");
523    fields.remove("title");
524
525    let mut lines = vec![
526        format!("List ID: {}", escape_terminal_controls(&data.list_id)),
527        format!("Title: {}", escape_terminal_controls(&data.title)),
528    ];
529    for (name, value) in fields {
530        if !value.is_null() {
531            render_named_value(&mut lines, 0, &name, &value);
532        }
533    }
534    lines.join("\n")
535}
536
537fn render_list_created(data: &ListCreateSuccess) -> String {
538    format!(
539        "Created list {} on board {}.",
540        escape_terminal_controls(&data.list_id),
541        escape_terminal_controls(&data.board_id)
542    )
543}
544
545fn render_list_updated(data: &ListUpdateSuccess) -> String {
546    let fields = data
547        .updated_fields
548        .iter()
549        .map(|field| {
550            serde_json::to_value(field)
551                .expect("updated list fields are always serializable")
552                .as_str()
553                .expect("updated list fields serialize as strings")
554                .to_owned()
555        })
556        .collect::<Vec<_>>()
557        .join(", ");
558    format!(
559        "Updated list {} on board {}: {}.",
560        escape_terminal_controls(&data.list_id),
561        escape_terminal_controls(&data.board_id),
562        escape_terminal_controls(&fields)
563    )
564}
565
566fn render_list_deleted(data: &ListDeleteSuccess) -> String {
567    format!(
568        "Soft-deleted list {} on board {} and its live cards.",
569        escape_terminal_controls(&data.list_id),
570        escape_terminal_controls(&data.board_id)
571    )
572}
573
574fn render_card_collection(data: &CardCollectionSuccess) -> String {
575    if data.cards.is_empty() {
576        return format!(
577            "No cards found in list {} on board {}.",
578            escape_terminal_controls(&data.list_id),
579            escape_terminal_controls(&data.board_id)
580        );
581    }
582    let mut lines = vec![
583        "ID  TITLE  DESCRIPTION  SWIMLANE  RECEIVED  START  DUE  END  ASSIGNEES  SORT".to_owned(),
584    ];
585    lines.extend(data.cards.iter().map(|card| {
586        format!(
587            "{}  {}  {}  {}  {}  {}  {}  {}  {}  {}",
588            escape_terminal_controls(&card.card_id),
589            escape_terminal_controls(card.title.as_deref().unwrap_or("")),
590            escape_terminal_controls(card.description.as_deref().unwrap_or("")),
591            escape_terminal_controls(card.swimlane_id.as_deref().unwrap_or("")),
592            escape_terminal_controls(card.received_at.as_deref().unwrap_or("")),
593            escape_terminal_controls(card.start_at.as_deref().unwrap_or("")),
594            escape_terminal_controls(card.due_at.as_deref().unwrap_or("")),
595            escape_terminal_controls(card.end_at.as_deref().unwrap_or("")),
596            escape_terminal_controls(&card.assignees.join(",")),
597            card.sort
598                .as_ref()
599                .map(ToString::to_string)
600                .unwrap_or_default(),
601        )
602    }));
603    lines.join("\n")
604}
605
606fn render_card_detail(data: &CardDetail) -> String {
607    let value = serde_json::to_value(data).expect("card documents are always serializable");
608    let serde_json::Value::Object(mut fields) = value else {
609        unreachable!("card documents serialize as objects")
610    };
611    fields.remove("card_id");
612
613    let mut lines = vec![format!(
614        "Card ID: {}",
615        escape_terminal_controls(&data.card_id)
616    )];
617    for (name, value) in fields {
618        render_named_value_complete(&mut lines, 0, &name, &value);
619    }
620    lines.join("\n")
621}
622
623fn render_card_created(data: &CardCreateSuccess) -> String {
624    format!(
625        "Created card {} in list {} on board {}.",
626        escape_terminal_controls(&data.card_id),
627        escape_terminal_controls(&data.list_id),
628        escape_terminal_controls(&data.board_id)
629    )
630}
631
632fn render_card_updated(data: &CardUpdateSuccess) -> String {
633    let fields = data
634        .submitted_fields
635        .iter()
636        .map(|field| {
637            serde_json::to_value(field)
638                .expect("submitted card fields are always serializable")
639                .as_str()
640                .expect("submitted card fields serialize as strings")
641                .to_owned()
642        })
643        .collect::<Vec<_>>()
644        .join(", ");
645    format!(
646        "Updated card {} in list {} on board {}; submitted fields: {}.",
647        escape_terminal_controls(&data.card_id),
648        escape_terminal_controls(&data.list_id),
649        escape_terminal_controls(&data.board_id),
650        escape_terminal_controls(&fields)
651    )
652}
653
654fn render_card_deleted(data: &CardDeleteSuccess) -> String {
655    format!(
656        "Permanently deleted card {} from list {} on board {}.",
657        escape_terminal_controls(&data.card_id),
658        escape_terminal_controls(&data.list_id),
659        escape_terminal_controls(&data.board_id)
660    )
661}
662
663fn render_comment_collection(data: &CommentCollectionSuccess) -> String {
664    if data.comments.is_empty() {
665        return format!(
666            "No comments found on card {} on board {}.",
667            escape_terminal_controls(&data.card_id),
668            escape_terminal_controls(&data.board_id)
669        );
670    }
671    let mut lines = vec!["ID  TEXT  AUTHOR".to_owned()];
672    lines.extend(data.comments.iter().map(|comment| {
673        format!(
674            "{}  {}  {}",
675            escape_terminal_controls(&comment.comment_id),
676            escape_terminal_controls(&comment.text),
677            escape_terminal_controls(&comment.author_id)
678        )
679    }));
680    lines.join("\n")
681}
682
683fn render_comment_detail(data: &CommentDetail) -> String {
684    let value = serde_json::to_value(data).expect("comment documents are always serializable");
685    let serde_json::Value::Object(mut fields) = value else {
686        unreachable!("comment documents serialize as objects")
687    };
688    fields.remove("comment_id");
689    fields.remove("text");
690
691    let mut lines = vec![
692        format!("Comment ID: {}", escape_terminal_controls(&data.comment_id)),
693        format!("Text: {}", escape_terminal_controls(&data.text)),
694    ];
695    for (name, value) in fields {
696        if !value.is_null() {
697            render_named_value(&mut lines, 0, &name, &value);
698        }
699    }
700    lines.join("\n")
701}
702
703fn render_comment_created(data: &CommentCreateSuccess) -> String {
704    format!(
705        "Created comment {} on card {} on board {}.",
706        escape_terminal_controls(&data.comment_id),
707        escape_terminal_controls(&data.card_id),
708        escape_terminal_controls(&data.board_id)
709    )
710}
711
712fn render_comment_deleted(data: &CommentDeleteSuccess) -> String {
713    format!(
714        "Permanently deleted comment {} from card {} on board {}.",
715        escape_terminal_controls(&data.comment_id),
716        escape_terminal_controls(&data.card_id),
717        escape_terminal_controls(&data.board_id)
718    )
719}
720
721fn render_swimlane_collection(data: &SwimlaneCollectionSuccess) -> String {
722    if data.swimlanes.is_empty() {
723        return format!(
724            "No swimlanes found on board {}.",
725            escape_terminal_controls(&data.board_id)
726        );
727    }
728    let mut lines = vec!["ID  TITLE".to_owned()];
729    lines.extend(data.swimlanes.iter().map(|swimlane| {
730        format!(
731            "{}  {}",
732            escape_terminal_controls(&swimlane.swimlane_id),
733            escape_terminal_controls(&swimlane.title),
734        )
735    }));
736    lines.join("\n")
737}
738
739fn render_swimlane_detail(data: &SwimlaneDetail) -> String {
740    let value = serde_json::to_value(data).expect("swimlane documents are always serializable");
741    let serde_json::Value::Object(mut fields) = value else {
742        unreachable!("swimlane documents serialize as objects")
743    };
744    fields.remove("swimlane_id");
745    fields.remove("title");
746
747    let mut lines = vec![
748        format!(
749            "Swimlane ID: {}",
750            escape_terminal_controls(&data.swimlane_id)
751        ),
752        format!("Title: {}", escape_terminal_controls(&data.title)),
753    ];
754    for (name, value) in fields {
755        if !value.is_null() {
756            render_named_value(&mut lines, 0, &name, &value);
757        }
758    }
759    lines.join("\n")
760}
761
762fn render_swimlane_created(data: &SwimlaneCreateSuccess) -> String {
763    format!(
764        "Created swimlane {} on board {}.",
765        escape_terminal_controls(&data.swimlane_id),
766        escape_terminal_controls(&data.board_id)
767    )
768}
769
770fn render_swimlane_updated(data: &SwimlaneUpdateSuccess) -> String {
771    let fields = data
772        .updated_fields
773        .iter()
774        .map(|field| {
775            serde_json::to_value(field)
776                .expect("updated swimlane fields are always serializable")
777                .as_str()
778                .expect("updated swimlane fields serialize as strings")
779                .to_owned()
780        })
781        .collect::<Vec<_>>()
782        .join(", ");
783    format!(
784        "Updated swimlane {} on board {}: {}.",
785        escape_terminal_controls(&data.swimlane_id),
786        escape_terminal_controls(&data.board_id),
787        escape_terminal_controls(&fields)
788    )
789}
790
791fn render_swimlane_deleted(data: &SwimlaneDeleteSuccess) -> String {
792    format!(
793        "Permanently deleted swimlane {} on board {}.",
794        escape_terminal_controls(&data.swimlane_id),
795        escape_terminal_controls(&data.board_id)
796    )
797}
798
799fn render_board_detail(data: &BoardDetail) -> String {
800    let value = serde_json::to_value(data).expect("board documents are always serializable");
801    let serde_json::Value::Object(mut fields) = value else {
802        unreachable!("board documents serialize as objects")
803    };
804    fields.remove("board_id");
805    fields.remove("title");
806
807    let mut groups = [
808        ("Core", Vec::new()),
809        ("Sharing", Vec::new()),
810        ("Appearance", Vec::new()),
811        ("Dates and defaults", Vec::new()),
812        ("Settings", Vec::new()),
813    ];
814    for (name, value) in fields {
815        if value.is_null() {
816            continue;
817        }
818        let group = board_field_group(&name);
819        groups[group].1.push((name, value));
820    }
821
822    let mut lines = vec![
823        format!("Board ID: {}", escape_terminal_controls(&data.board_id)),
824        format!("Title: {}", escape_terminal_controls(&data.title)),
825    ];
826    for (heading, values) in groups {
827        if values.is_empty() {
828            continue;
829        }
830        lines.push(format!("{heading}:"));
831        for (name, value) in values {
832            render_named_value(&mut lines, 2, &name, &value);
833        }
834    }
835    lines.join("\n")
836}
837
838fn board_field_group(name: &str) -> usize {
839    if matches!(
840        name,
841        "members" | "watchers" | "labels" | "orgs" | "teams" | "domains" | "import_usernames"
842    ) {
843        1
844    } else if matches!(
845        name,
846        "color" | "custom_theme_colors" | "background_image_url" | "background_image_id"
847    ) {
848        2
849    } else if name.contains("_at")
850        || name.ends_with("_board_id")
851        || name.ends_with("_list_id")
852        || matches!(name, "spent_time" | "is_overtime")
853    {
854        3
855    } else if name.starts_with("allows_")
856        || name.starts_with("card_aging")
857        || matches!(
858            name,
859            "show_dependencies" | "restrict_comment_editing" | "auto_width" | "present_parent_task"
860        )
861    {
862        4
863    } else {
864        0
865    }
866}
867
868fn render_named_value(
869    lines: &mut Vec<String>,
870    indent: usize,
871    name: &str,
872    value: &serde_json::Value,
873) {
874    let padding = " ".repeat(indent);
875    match value {
876        serde_json::Value::Array(values) => {
877            lines.push(format!("{padding}{name}: [{}]", values.len()));
878            for (index, value) in values.iter().enumerate() {
879                render_named_value(lines, indent + 2, &format!("[{index}]"), value);
880            }
881        }
882        serde_json::Value::Object(values) => {
883            lines.push(format!("{padding}{name}:"));
884            for (name, value) in values {
885                if !value.is_null() {
886                    render_named_value(lines, indent + 2, name, value);
887                }
888            }
889        }
890        serde_json::Value::String(value) => lines.push(format!(
891            "{padding}{name}: {}",
892            escape_terminal_controls(value)
893        )),
894        other => lines.push(format!("{padding}{name}: {other}")),
895    }
896}
897
898fn render_named_value_complete(
899    lines: &mut Vec<String>,
900    indent: usize,
901    name: &str,
902    value: &serde_json::Value,
903) {
904    let padding = " ".repeat(indent);
905    match value {
906        serde_json::Value::Array(values) => {
907            lines.push(format!("{padding}{name}: [{}]", values.len()));
908            for (index, value) in values.iter().enumerate() {
909                render_named_value_complete(lines, indent + 2, &format!("[{index}]"), value);
910            }
911        }
912        serde_json::Value::Object(values) => {
913            lines.push(format!("{padding}{name}:"));
914            for (name, value) in values {
915                render_named_value_complete(lines, indent + 2, name, value);
916            }
917        }
918        serde_json::Value::String(value) => lines.push(format!(
919            "{padding}{name}: {}",
920            escape_terminal_controls(value)
921        )),
922        other => lines.push(format!("{padding}{name}: {other}")),
923    }
924}
925
926fn two_column_table(left_header: &str, right_header: &str, rows: &[(String, String)]) -> String {
927    let left_width = rows
928        .iter()
929        .map(|(left, _)| left.len())
930        .max()
931        .unwrap_or(left_header.len())
932        .max(left_header.len());
933    let mut lines = vec![format!("{left_header:<left_width$}  {right_header}")];
934    lines.extend(
935        rows.iter()
936            .map(|(left, right)| format!("{left:<left_width$}  {right}")),
937    );
938    lines.join("\n")
939}
940
941fn render_auth_success(action: &str, data: &AuthSuccess) -> String {
942    let mut lines = vec![format!(
943        "{action} {} on {}",
944        escape_terminal_controls(&data.user_id),
945        escape_terminal_controls(&data.server)
946    )];
947    lines.push(format!(
948        "Profile: {}",
949        escape_terminal_controls(&data.profile)
950    ));
951    lines.push(format!(
952        "Profile created: {}",
953        if data.profile_created { "yes" } else { "no" }
954    ));
955    lines.push(format!(
956        "Profile active: {}",
957        if data.profile_active { "yes" } else { "no" }
958    ));
959    lines.push(format!(
960        "Token expires: {}",
961        escape_terminal_controls(&data.token_expires)
962    ));
963    lines.push("Credentials stored securely.".to_owned());
964    lines.join("\n")
965}
966
967fn render_profile_item(action: &str, data: &ProfileItem) -> String {
968    format!(
969        "{action}: {}\nServer: {}\nActive: {}",
970        escape_terminal_controls(&data.name),
971        escape_terminal_controls(&data.server),
972        if data.active { "yes" } else { "no" }
973    )
974}
975
976fn render_profile_list(data: &ProfileListSuccess) -> String {
977    if data.profiles.is_empty() {
978        return "No profiles configured.".to_owned();
979    }
980    let name_width = data
981        .profiles
982        .iter()
983        .map(|profile| profile.name.len())
984        .max()
985        .unwrap_or(4)
986        .max(4);
987    let server_width = data
988        .profiles
989        .iter()
990        .map(|profile| profile.server.len())
991        .max()
992        .unwrap_or(6)
993        .max(6);
994    let mut lines = vec![format!(
995        "{:<name_width$}  {:<server_width$}  ACTIVE",
996        "NAME", "SERVER"
997    )];
998    lines.extend(data.profiles.iter().map(|profile| {
999        format!(
1000            "{:<name_width$}  {:<server_width$}  {}",
1001            escape_terminal_controls(&profile.name),
1002            escape_terminal_controls(&profile.server),
1003            if profile.active { "yes" } else { "no" }
1004        )
1005    }));
1006    lines.join("\n")
1007}
1008
1009fn render_profile_removed(data: &ProfileRemoveSuccess) -> String {
1010    let mut message = format!(
1011        "Removed profile: {}\nServer: {}",
1012        escape_terminal_controls(&data.name),
1013        escape_terminal_controls(&data.server)
1014    );
1015    if data.active_profile.is_none() {
1016        message.push_str("\nNo profile is active.");
1017    }
1018    message
1019}
1020
1021fn render_logout(data: &LogoutSuccess) -> String {
1022    let server = escape_terminal_controls(&data.server);
1023    let mut rendered = match data.logout_scope {
1024        LogoutScope::CurrentToken => format!(
1025            "Logged out from {server}.\nCurrent login token revoked.\n{}",
1026            render_local_credential_state(data)
1027        ),
1028        LogoutScope::AllTokens => format!(
1029            "Logged out from {server}.\nAll login tokens revoked.\n{}",
1030            render_local_credential_state(data)
1031        ),
1032        LogoutScope::LocalOnly => format!(
1033            "{} for {server}.\nNo Wekan login tokens were revoked.",
1034            if data.local_credential_removed {
1035                "Removed the stored credential"
1036            } else {
1037                "No stored credential was present"
1038            }
1039        ),
1040    };
1041    let first_newline = rendered.find('\n').unwrap_or(rendered.len());
1042    rendered.insert_str(
1043        first_newline,
1044        &format!("\nProfile: {}", escape_terminal_controls(&data.profile)),
1045    );
1046    rendered
1047}
1048
1049fn render_local_credential_state(data: &LogoutSuccess) -> &'static str {
1050    if data.local_credential_removed {
1051        "Stored credential removed."
1052    } else if data.credential_stored {
1053        match data.logout_scope {
1054            LogoutScope::AllTokens => {
1055                "A concurrently changed stored credential was preserved; verify it before use."
1056            }
1057            LogoutScope::CurrentToken | LogoutScope::LocalOnly => {
1058                "A newer stored credential was preserved."
1059            }
1060        }
1061    } else {
1062        "Stored credential was already absent."
1063    }
1064}
1065
1066fn render_auth_status(data: &AuthStatusSuccess) -> String {
1067    let identity = data.user.username.as_deref().unwrap_or(&data.user.user_id);
1068    let mut lines = vec![
1069        format!(
1070            "Authenticated as {} on {}",
1071            escape_terminal_controls(identity),
1072            escape_terminal_controls(&data.server)
1073        ),
1074        format!("User ID: {}", escape_terminal_controls(&data.user.user_id)),
1075    ];
1076
1077    lines.push(format!(
1078        "Profile: {}",
1079        escape_terminal_controls(&data.profile)
1080    ));
1081
1082    if let Some(full_name) = &data.user.full_name {
1083        lines.push(format!(
1084            "Full name: {}",
1085            escape_terminal_controls(full_name)
1086        ));
1087    }
1088    if let Some(is_admin) = data.user.is_admin {
1089        lines.push(format!(
1090            "Administrator: {}",
1091            if is_admin { "yes" } else { "no" }
1092        ));
1093    }
1094    if !data.user.emails.is_empty() {
1095        let emails = data
1096            .user
1097            .emails
1098            .iter()
1099            .map(|email| {
1100                let address = email.address.as_deref().unwrap_or("unknown address");
1101                let verification = match email.verified {
1102                    Some(true) => "verified",
1103                    Some(false) => "unverified",
1104                    None => "verification unknown",
1105                };
1106                format!("{} ({verification})", escape_terminal_controls(address))
1107            })
1108            .collect::<Vec<_>>()
1109            .join(", ");
1110        lines.push(format!("Emails: {emails}"));
1111    }
1112    lines.push(format!(
1113        "Token expires: {}",
1114        escape_terminal_controls(&data.token_expires)
1115    ));
1116    lines.push("Credentials stored securely.".to_owned());
1117    lines.join("\n")
1118}
1119
1120fn escape_terminal_controls(value: &str) -> String {
1121    let mut escaped = String::with_capacity(value.len());
1122    for character in value.chars() {
1123        if character.is_control() {
1124            escaped.extend(character.escape_default());
1125        } else {
1126            escaped.push(character);
1127        }
1128    }
1129    escaped
1130}
1131
1132pub fn render_error(format: OutputFormat, error: &AppError) -> String {
1133    match format {
1134        OutputFormat::Human => {
1135            let mut rendered = format!(
1136                "Error [{}]: {}",
1137                error.code().as_str(),
1138                error.message().trim()
1139            );
1140            if let Some(response) = &error.details().response {
1141                rendered.push('\n');
1142                rendered.push_str(&render_api_response_human(response));
1143            }
1144            rendered
1145        }
1146        OutputFormat::Json => serde_json::to_string(&ErrorEnvelope {
1147            ok: false,
1148            error: ErrorBody {
1149                code: error.code().as_str(),
1150                message: error.message(),
1151                details: error.details(),
1152            },
1153        })
1154        .expect("application errors are always serializable"),
1155        OutputFormat::Raw => format!(
1156            "Error [{}]: {}",
1157            error.code().as_str(),
1158            error.message().trim()
1159        ),
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests;