1use std::fmt::Write as _;
4
5use crate::api::{
6 Automation, Component, FieldSpec, Holder, Permission, Queue, QueueAccess, QueueField,
7 QueueSettings, Template, Unreadable,
8};
9use crate::render::Context;
10use crate::render::style::Palette;
11use crate::render::table::{Column, render, tally};
12
13#[must_use]
16pub fn queues(queues: &[Queue], ctx: &Context) -> String {
17 let columns = [
18 Column::whole("KEY", 12, Palette::key()),
19 Column::whole("NAME", 28, anstyle::Style::new()),
20 Column::whole("LEAD", 20, anstyle::Style::new()),
21 ];
22 let rows: Vec<Vec<String>> = queues
23 .iter()
24 .map(|queue| {
25 vec![
26 queue.key.clone(),
27 queue.name.clone(),
28 queue.lead.as_deref().unwrap_or("-").to_owned(),
29 ]
30 })
31 .collect();
32
33 let mut out = render(&columns, &rows, ctx);
34 out.push_str(&tally(queues.len(), Some(queues.len() as u64), None, ctx));
35 out
36}
37
38#[must_use]
45pub fn fields(fields: &[QueueField], ctx: &Context) -> String {
46 let columns = [
47 Column::whole("KEY", 28, Palette::key()),
48 Column::whole("TYPE", 12, anstyle::Style::new()),
49 Column::by_value("ORIGIN", 8, |origin| {
52 if origin == "custom" {
53 Palette::warn()
54 } else {
55 Palette::label()
56 }
57 }),
58 Column::whole("NAME", 30, anstyle::Style::new()),
59 ];
60 let rows: Vec<Vec<String>> = fields
61 .iter()
62 .map(|field| {
63 vec![
64 field.key.clone(),
65 field.field_type.clone(),
66 if field.system { "system" } else { "custom" }.to_owned(),
67 field.name.clone(),
68 ]
69 })
70 .collect();
71
72 let mut out = render(&columns, &rows, ctx);
73
74 let custom = fields.iter().filter(|field| !field.system).count();
75 let paint = ctx.painter();
76 let _ = writeln!(
77 out,
78 "{}",
79 paint.paint(
80 &format!(
81 "shown {} of {} ({custom} custom)",
82 fields.len(),
83 fields.len()
84 ),
85 Palette::label()
86 )
87 );
88 out
89}
90
91const VALUES_SHOWN: usize = 20;
97
98#[must_use]
104pub fn field_spec(field: &FieldSpec, all: bool, ctx: &Context) -> String {
105 let mut out = String::with_capacity(240);
106 let paint = ctx.painter();
107 let label = |text: &str| paint.paint(text, Palette::label());
108 let yes_no = |flag: bool| if flag { "yes" } else { "no" };
109
110 let _ = writeln!(
111 out,
112 "{} {}",
113 paint.paint(&field.key, Palette::key()),
114 field.name
115 );
116
117 let kind = match &field.items {
121 Some(items) => format!("{} of {items}", field.field_type),
122 None => field.field_type.clone(),
123 };
124 let _ = writeln!(
125 out,
126 "{} {kind} {} {} {} {}",
127 label("type:"),
128 label("required:"),
129 yes_no(field.required),
130 label("readonly:"),
131 yes_no(field.readonly),
132 );
133 if let Some(category) = &field.category {
134 let _ = writeln!(out, "{} {category}", label("category:"));
135 }
136
137 out.push_str(&values(field, all, ctx));
138 out
139}
140
141fn values(field: &FieldSpec, all: bool, ctx: &Context) -> String {
143 let paint = ctx.painter();
144 let label = |text: &str| paint.paint(text, Palette::label());
145
146 let Some(options) = &field.options else {
147 return format!("{} anything of that type\n", label("values:"));
148 };
149
150 if options.values.is_empty() {
151 return match provider_source(&options.provider) {
156 Some((what, command)) => format!("{} {what} — {command}\n", label("values:")),
157 None => format!(
158 "{} decided by {} — not listed by this endpoint\n",
159 label("values:"),
160 options.provider
161 ),
162 };
163 }
164
165 let mut out = String::with_capacity(64 + options.values.len() * 12);
166 let shown = if all {
167 options.values.len()
168 } else {
169 options.values.len().min(VALUES_SHOWN)
170 };
171 let _ = writeln!(
172 out,
173 "{} {}",
174 label("values:"),
175 options.values[..shown].join(", ")
176 );
177 let _ = writeln!(
178 out,
179 "{}",
180 paint.paint(
181 &if shown < options.values.len() {
182 format!(
183 "shown {shown} of {} values; --all for the rest",
184 options.values.len()
185 )
186 } else {
187 format!("shown {shown} of {shown} values")
188 },
189 Palette::label()
190 )
191 );
192 out
193}
194
195fn provider_source(provider: &str) -> Option<(&'static str, &'static str)> {
202 Some(match provider {
203 "TeamOptionsProvider" => ("people in the organisation", "ytcli user list"),
204 "QueueOptionsProvider" => ("queue keys", "ytcli queue list"),
205 "IssueTypeOptionsProvider" => ("issue types", "ytcli dict list --kind types"),
206 "PriorityOptionsProvider" => ("priorities", "ytcli dict list --kind priorities"),
207 "StatusOptionsProvider" => ("statuses", "ytcli dict list --kind statuses"),
208 "ResolutionOptionsProvider" => ("resolutions", "ytcli dict list --kind resolutions"),
209 "VersionOptionsProvider" => ("versions of the queue", "ytcli queue versions PROJ"),
210 "TagOptionsProvider" => ("tags in use in the queue", "ytcli queue tags PROJ"),
211 "SprintOptionsProvider" => ("sprints", "ytcli sprint list"),
212 "BoardOptionsProvider" => ("boards", "ytcli board list"),
213 "ProjectOptionsProvider" => ("projects", "ytcli project list"),
214 "MetaEntityOptionsProvider" => ("goals", "ytcli goal list"),
215 _ => return None,
216 })
217}
218
219#[must_use]
225pub fn local_fields(queue: &str, fields: &[FieldSpec], ctx: &Context) -> String {
226 let columns = [
227 Column::whole("KEY", 24, Palette::key()),
228 Column::whole("TYPE", 10, anstyle::Style::new()),
229 Column::new("NAME", 24, anstyle::Style::new()),
230 Column::new("ACCEPTS", 28, Palette::label()),
231 ];
232 let rows: Vec<Vec<String>> = fields
233 .iter()
234 .map(|field| {
235 vec![
236 field.key.clone(),
237 match &field.items {
238 Some(items) => format!("[{items}]"),
239 None => field.field_type.clone(),
240 },
241 field.name.clone(),
242 accepts(field),
243 ]
244 })
245 .collect();
246
247 let mut out = render(&columns, &rows, ctx);
248 out.push_str(&counted(queue, fields.len(), ctx));
249 out
250}
251
252fn accepts(field: &FieldSpec) -> String {
254 match &field.options {
255 None => "anything of that type".to_owned(),
256 Some(options) if options.values.is_empty() => provider_source(&options.provider)
259 .map_or_else(
260 || options.provider.clone(),
261 |(_, command)| command.to_owned(),
262 ),
263 Some(options) => options.values.join(", "),
264 }
265}
266
267#[must_use]
274pub fn components(components: &[Component], scope: Option<&str>, ctx: &Context) -> String {
275 let columns = [
276 Column::new("NAME", 28, Palette::key()),
277 Column::whole("ID", 8, anstyle::Style::new()),
278 Column::whole("QUEUE", 12, anstyle::Style::new()),
279 Column::new("LEAD", 20, anstyle::Style::new()),
280 Column::by_value("AUTO", 6, |value| {
281 if value == "yes" {
282 Palette::warn()
283 } else {
284 Palette::label()
285 }
286 }),
287 ];
288
289 let rows: Vec<Vec<String>> = components
290 .iter()
291 .map(|component| {
292 vec![
293 component.name.clone(),
294 component.id.clone(),
295 component.queue.clone().unwrap_or_else(|| "-".to_owned()),
296 component.lead.clone().unwrap_or_else(|| "-".to_owned()),
297 if component.assign_auto { "yes" } else { "no" }.to_owned(),
298 ]
299 })
300 .collect();
301
302 let mut out = render(&columns, &rows, ctx);
303 out.push_str(&match scope {
304 Some(queue) => counted(queue, components.len(), ctx),
305 None => tally(components.len(), Some(components.len() as u64), None, ctx),
306 });
307 out
308}
309
310#[must_use]
312pub fn versions(queue: &str, versions: &[crate::api::Version], ctx: &Context) -> String {
313 let columns = [
314 Column::whole("ID", 10, Palette::key()),
315 Column::new("NAME", 28, anstyle::Style::new()),
316 Column::by_value("STATE", 10, |state| match state {
319 "open" => Palette::ok(),
320 _ => Palette::label(),
321 }),
322 Column::whole("DUE", 12, anstyle::Style::new()),
323 ];
324
325 let rows: Vec<Vec<String>> = versions
326 .iter()
327 .map(|version| {
328 vec![
329 version.id.clone(),
330 version.name.clone(),
331 version.state.to_owned(),
332 version.due.clone().unwrap_or_else(|| "-".to_owned()),
333 ]
334 })
335 .collect();
336
337 let mut out = render(&columns, &rows, ctx);
338 out.push_str(&counted(queue, versions.len(), ctx));
339 out
340}
341
342#[must_use]
344pub fn tags(queue: &str, tags: &[String], ctx: &Context) -> String {
345 let columns = [Column::whole("TAG", 30, Palette::key())];
346 let rows: Vec<Vec<String>> = tags.iter().map(|tag| vec![tag.clone()]).collect();
347
348 let mut out = render(&columns, &rows, ctx);
349 out.push_str(&counted(queue, tags.len(), ctx));
350 out
351}
352
353fn counted(queue: &str, count: usize, ctx: &Context) -> String {
356 let paint = ctx.painter();
357 format!(
358 "{}\n",
359 paint.paint(
360 &format!("shown {count} of {count} for {queue}"),
361 Palette::label()
362 )
363 )
364}
365
366#[must_use]
373pub fn automation(queue: &str, automation: &Automation, ctx: &Context) -> String {
374 let mut out = String::with_capacity(320);
375 out.push_str(¯os_section(queue, automation, ctx));
376 out.push('\n');
377 out.push_str(&autoactions_section(queue, automation, ctx));
378 out.push('\n');
379 out.push_str(&triggers_section(queue, automation, ctx));
380 out
381}
382
383fn heading(text: &str, ctx: &Context) -> String {
384 format!("{}\n", ctx.painter().paint(text, Palette::heading()))
385}
386
387fn macros_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
389 let rows: Vec<Vec<String>> = automation
390 .macros
391 .iter()
392 .map(|entry| {
393 vec![
394 entry.id.clone(),
395 entry.name.clone(),
396 actions(&entry.updates),
397 if entry.body.is_some() { "yes" } else { "no" }.to_owned(),
401 ]
402 })
403 .collect();
404
405 let mut out = heading("macros", ctx);
406 out.push_str(&render(
407 &[
408 Column::whole("ID", 8, Palette::key()),
409 Column::new("NAME", 30, anstyle::Style::new()),
410 Column::new("SETS", 24, anstyle::Style::new()),
411 Column::whole("COMMENTS", 8, Palette::label()),
412 ],
413 &rows,
414 ctx,
415 ));
416 out.push_str(&closing(
417 queue,
418 "macros",
419 rows.len(),
420 &automation.unreadable,
421 ctx,
422 ));
423 out
424}
425
426fn autoactions_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
428 let rows: Vec<Vec<String>> = automation
429 .autoactions
430 .iter()
431 .map(|entry| {
432 vec![
433 entry.id.clone(),
434 entry.name.clone(),
435 active(entry.active).to_owned(),
436 entry
437 .interval
438 .map_or_else(|| "-".to_owned(), |seconds| format!("{seconds}s")),
439 actions(&entry.actions),
440 ]
441 })
442 .collect();
443
444 let mut out = heading("autoactions", ctx);
445 out.push_str(&render(
446 &[
447 Column::whole("ID", 8, Palette::key()),
448 Column::new("NAME", 28, anstyle::Style::new()),
449 Column::by_value("ACTIVE", 8, state_colour),
450 Column::whole("EVERY", 10, anstyle::Style::new()),
451 Column::new("DOES", 22, anstyle::Style::new()),
452 ],
453 &rows,
454 ctx,
455 ));
456 out.push_str(&closing(
457 queue,
458 "autoactions",
459 rows.len(),
460 &automation.unreadable,
461 ctx,
462 ));
463 out
464}
465
466fn triggers_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
468 let rows: Vec<Vec<String>> = automation
469 .triggers
470 .iter()
471 .map(|entry| {
472 vec![
473 entry.id.clone(),
474 entry.name.clone(),
475 active(entry.active).to_owned(),
476 entry.conditions.to_string(),
477 actions(&entry.actions),
478 ]
479 })
480 .collect();
481
482 let mut out = heading("triggers", ctx);
483 out.push_str(&render(
484 &[
485 Column::whole("ID", 8, Palette::key()),
486 Column::new("NAME", 28, anstyle::Style::new()),
487 Column::by_value("ACTIVE", 8, state_colour),
488 Column::whole("WHEN", 10, Palette::label()),
489 Column::new("DOES", 22, anstyle::Style::new()),
490 ],
491 &rows,
492 ctx,
493 ));
494 out.push_str(&closing(
495 queue,
496 "triggers",
497 rows.len(),
498 &automation.unreadable,
499 ctx,
500 ));
501 out
502}
503
504fn closing(
510 queue: &str,
511 section: &str,
512 count: usize,
513 unreadable: &[Unreadable],
514 ctx: &Context,
515) -> String {
516 match unreadable.iter().find(|refusal| refusal.section == section) {
517 Some(refusal) => {
518 let paint = ctx.painter();
519 format!(
520 "{}\n",
521 paint.paint(
522 &format!("not readable — {}", refusal.reason),
523 Palette::warn()
524 )
525 )
526 }
527 None => counted(queue, count, ctx),
528 }
529}
530
531#[must_use]
537pub fn access(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
538 let mut out = String::with_capacity(320);
539 out.push_str(&permissions_section(queue, access, ctx));
540 out.push('\n');
541 out.push_str(&access_section(queue, access, ctx));
542 out
543}
544
545fn permissions_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
547 let rows: Vec<Vec<String>> = access
548 .permissions
549 .iter()
550 .map(|entry| {
551 vec![
552 entry.operation.clone(),
553 granted_to(entry),
554 holders(&entry.users),
555 ]
556 })
557 .collect();
558
559 let mut out = heading("permissions", ctx);
560 out.push_str(&render(
561 &[
562 Column::whole("OPERATION", 14, Palette::key()),
563 Column::new("ROLES", 34, anstyle::Style::new()),
564 Column::new("USERS", 28, anstyle::Style::new()),
565 ],
566 &rows,
567 ctx,
568 ));
569 out.push_str(&closing(
570 queue,
571 "permissions",
572 rows.len(),
573 &access.unreadable,
574 ctx,
575 ));
576
577 if rows.iter().any(|row| row[1] != "-") {
581 let paint = ctx.painter();
582 let _ = writeln!(
583 out,
584 "{}",
585 paint.paint(
586 "a role is decided per issue: `assignee` is whoever that issue names",
587 Palette::label()
588 )
589 );
590 }
591 out
592}
593
594fn access_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
596 let rows: Vec<Vec<String>> = access
597 .access
598 .iter()
599 .map(|entry| {
600 vec![
601 entry.operation.clone(),
602 holds(entry, access.you.as_deref()).to_owned(),
603 holders(&entry.users),
604 ]
605 })
606 .collect();
607
608 let mut out = heading("access", ctx);
609 out.push_str(&render(
610 &[
611 Column::whole("OPERATION", 14, Palette::key()),
612 Column::by_value("YOU", 5, |held| match held {
613 "yes" => Palette::ok(),
614 "no" => Palette::warn(),
615 _ => Palette::label(),
616 }),
617 Column::new("USERS", 44, anstyle::Style::new()),
618 ],
619 &rows,
620 ctx,
621 ));
622 out.push_str(&closing(
623 queue,
624 "access",
625 rows.len(),
626 &access.unreadable,
627 ctx,
628 ));
629 out
630}
631
632fn holds(permission: &Permission, you: Option<&str>) -> &'static str {
638 match you {
639 Some(id) if permission.users.iter().any(|holder| holder.id == id) => "yes",
640 Some(_) => "no",
641 None => "?",
642 }
643}
644
645fn granted_to(permission: &Permission) -> String {
651 let mut names: Vec<String> = permission
655 .groups
656 .iter()
657 .map(|group| format!("group:{}", group.display))
658 .collect();
659 names.extend(permission.roles.iter().map(|role| role.id.clone()));
660
661 if names.is_empty() {
662 "-".to_owned()
663 } else {
664 names.join(", ")
665 }
666}
667
668fn holders(list: &[Holder]) -> String {
674 if list.is_empty() {
675 return "-".to_owned();
676 }
677 let names: Vec<&str> = list.iter().map(|holder| holder.display.as_str()).collect();
678 format!("{}: {}", list.len(), names.join(", "))
679}
680
681fn active(flag: bool) -> &'static str {
682 if flag { "on" } else { "off" }
683}
684
685fn state_colour(state: &str) -> anstyle::Style {
686 if state == "on" {
687 Palette::ok()
688 } else {
689 Palette::label()
690 }
691}
692
693fn actions(actions: &[String]) -> String {
694 if actions.is_empty() {
695 "-".to_owned()
696 } else {
697 actions.join(", ")
698 }
699}
700
701#[must_use]
703pub fn settings(queue: &QueueSettings, ctx: &Context) -> String {
704 let mut out = String::with_capacity(200);
705 let paint = ctx.painter();
706 let label = |text: &str| paint.paint(text, Palette::label());
707
708 let _ = writeln!(
709 out,
710 "{} {}",
711 paint.paint(&queue.key, Palette::key()),
712 queue.name
713 );
714 let _ = writeln!(
715 out,
716 "{} {} {} {} {} {}",
717 label("lead:"),
718 queue.lead.as_deref().unwrap_or("-"),
719 label("default type:"),
720 queue.default_type.as_deref().unwrap_or("-"),
721 label("default priority:"),
722 queue.default_priority.as_deref().unwrap_or("-"),
723 );
724
725 out
726}
727
728#[must_use]
733pub fn templates(templates: &[Template], ctx: &Context) -> String {
734 let columns = [
735 Column::whole("ID", 12, Palette::key()),
736 Column::new("NAME", 36, anstyle::Style::new()),
737 Column::whole("QUEUE", 12, anstyle::Style::new()),
738 Column::new("AUTHOR", 18, Palette::label()),
739 ];
740 let rows: Vec<Vec<String>> = templates
741 .iter()
742 .map(|template| {
743 vec![
744 template.id.clone(),
745 template.name.clone(),
746 template.queue.as_deref().unwrap_or("-").to_owned(),
747 template.author.as_deref().unwrap_or("-").to_owned(),
748 ]
749 })
750 .collect();
751
752 let mut out = render(&columns, &rows, ctx);
753 out.push_str(&tally(
754 templates.len(),
755 Some(templates.len() as u64),
756 None,
757 ctx,
758 ));
759 out
760}
761
762#[cfg(test)]
763#[allow(clippy::expect_used)]
764mod tests {
765 use super::*;
766
767 fn ctx() -> Context {
768 Context {
769 format: crate::render::Format::Text,
770 audience: crate::render::Audience::Machine,
771 description_lines: Some(10),
772 extra_fields: Vec::new(),
773 width: 80,
774 images: false,
775 inline: crate::render::image::Inline::default(),
776 }
777 }
778
779 #[test]
780 fn field_listing_marks_custom_fields_and_counts_them() {
781 let listing = fields(
782 &[
783 QueueField {
784 key: "summary".to_owned(),
785 name: "Summary".to_owned(),
786 field_type: "string".to_owned(),
787 system: true,
788 },
789 QueueField {
790 key: "storyPoints".to_owned(),
791 name: "Story points".to_owned(),
792 field_type: "number".to_owned(),
793 system: false,
794 },
795 ],
796 &ctx(),
797 );
798
799 assert!(listing.contains("summary"));
800 assert!(listing.contains("system"));
801 assert!(listing.contains("storyPoints"));
802 assert!(listing.ends_with("shown 2 of 2 (1 custom)\n"));
803 }
804}