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;