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;