use std::fmt::Write as _;
use crate::api::{
Automation, Component, FieldSpec, Holder, Permission, Queue, QueueAccess, QueueField,
QueueSettings, Template, Unreadable,
};
use crate::render::Context;
use crate::render::style::Palette;
use crate::render::table::{Column, render, tally};
#[must_use]
pub fn queues(queues: &[Queue], ctx: &Context) -> String {
let columns = [
Column::whole("KEY", 12, Palette::key()),
Column::whole("NAME", 28, anstyle::Style::new()),
Column::whole("LEAD", 20, anstyle::Style::new()),
];
let rows: Vec<Vec<String>> = queues
.iter()
.map(|queue| {
vec![
queue.key.clone(),
queue.name.clone(),
queue.lead.as_deref().unwrap_or("-").to_owned(),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&tally(queues.len(), Some(queues.len() as u64), None, ctx));
out
}
#[must_use]
pub fn fields(fields: &[QueueField], ctx: &Context) -> String {
let columns = [
Column::whole("KEY", 28, Palette::key()),
Column::whole("TYPE", 12, anstyle::Style::new()),
Column::by_value("ORIGIN", 8, |origin| {
if origin == "custom" {
Palette::warn()
} else {
Palette::label()
}
}),
Column::whole("NAME", 30, anstyle::Style::new()),
];
let rows: Vec<Vec<String>> = fields
.iter()
.map(|field| {
vec![
field.key.clone(),
field.field_type.clone(),
if field.system { "system" } else { "custom" }.to_owned(),
field.name.clone(),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
let custom = fields.iter().filter(|field| !field.system).count();
let paint = ctx.painter();
let _ = writeln!(
out,
"{}",
paint.paint(
&format!(
"shown {} of {} ({custom} custom)",
fields.len(),
fields.len()
),
Palette::label()
)
);
out
}
const VALUES_SHOWN: usize = 20;
#[must_use]
pub fn field_spec(field: &FieldSpec, all: bool, ctx: &Context) -> String {
let mut out = String::with_capacity(240);
let paint = ctx.painter();
let label = |text: &str| paint.paint(text, Palette::label());
let yes_no = |flag: bool| if flag { "yes" } else { "no" };
let _ = writeln!(
out,
"{} {}",
paint.paint(&field.key, Palette::key()),
field.name
);
let kind = match &field.items {
Some(items) => format!("{} of {items}", field.field_type),
None => field.field_type.clone(),
};
let _ = writeln!(
out,
"{} {kind} {} {} {} {}",
label("type:"),
label("required:"),
yes_no(field.required),
label("readonly:"),
yes_no(field.readonly),
);
if let Some(category) = &field.category {
let _ = writeln!(out, "{} {category}", label("category:"));
}
out.push_str(&values(field, all, ctx));
out
}
fn values(field: &FieldSpec, all: bool, ctx: &Context) -> String {
let paint = ctx.painter();
let label = |text: &str| paint.paint(text, Palette::label());
let Some(options) = &field.options else {
return format!("{} anything of that type\n", label("values:"));
};
if options.values.is_empty() {
return match provider_source(&options.provider) {
Some((what, command)) => format!("{} {what} — {command}\n", label("values:")),
None => format!(
"{} decided by {} — not listed by this endpoint\n",
label("values:"),
options.provider
),
};
}
let mut out = String::with_capacity(64 + options.values.len() * 12);
let shown = if all {
options.values.len()
} else {
options.values.len().min(VALUES_SHOWN)
};
let _ = writeln!(
out,
"{} {}",
label("values:"),
options.values[..shown].join(", ")
);
let _ = writeln!(
out,
"{}",
paint.paint(
&if shown < options.values.len() {
format!(
"shown {shown} of {} values; --all for the rest",
options.values.len()
)
} else {
format!("shown {shown} of {shown} values")
},
Palette::label()
)
);
out
}
fn provider_source(provider: &str) -> Option<(&'static str, &'static str)> {
Some(match provider {
"TeamOptionsProvider" => ("people in the organisation", "ytcli user list"),
"QueueOptionsProvider" => ("queue keys", "ytcli queue list"),
"IssueTypeOptionsProvider" => ("issue types", "ytcli dict list --kind types"),
"PriorityOptionsProvider" => ("priorities", "ytcli dict list --kind priorities"),
"StatusOptionsProvider" => ("statuses", "ytcli dict list --kind statuses"),
"ResolutionOptionsProvider" => ("resolutions", "ytcli dict list --kind resolutions"),
"VersionOptionsProvider" => ("versions of the queue", "ytcli queue versions PROJ"),
"TagOptionsProvider" => ("tags in use in the queue", "ytcli queue tags PROJ"),
"SprintOptionsProvider" => ("sprints", "ytcli sprint list"),
"BoardOptionsProvider" => ("boards", "ytcli board list"),
"ProjectOptionsProvider" => ("projects", "ytcli project list"),
"MetaEntityOptionsProvider" => ("goals", "ytcli goal list"),
_ => return None,
})
}
#[must_use]
pub fn local_fields(queue: &str, fields: &[FieldSpec], ctx: &Context) -> String {
let columns = [
Column::whole("KEY", 24, Palette::key()),
Column::whole("TYPE", 10, anstyle::Style::new()),
Column::new("NAME", 24, anstyle::Style::new()),
Column::new("ACCEPTS", 28, Palette::label()),
];
let rows: Vec<Vec<String>> = fields
.iter()
.map(|field| {
vec![
field.key.clone(),
match &field.items {
Some(items) => format!("[{items}]"),
None => field.field_type.clone(),
},
field.name.clone(),
accepts(field),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&counted(queue, fields.len(), ctx));
out
}
fn accepts(field: &FieldSpec) -> String {
match &field.options {
None => "anything of that type".to_owned(),
Some(options) if options.values.is_empty() => provider_source(&options.provider)
.map_or_else(
|| options.provider.clone(),
|(_, command)| command.to_owned(),
),
Some(options) => options.values.join(", "),
}
}
#[must_use]
pub fn components(components: &[Component], scope: Option<&str>, ctx: &Context) -> String {
let columns = [
Column::new("NAME", 28, Palette::key()),
Column::whole("ID", 8, anstyle::Style::new()),
Column::whole("QUEUE", 12, anstyle::Style::new()),
Column::new("LEAD", 20, anstyle::Style::new()),
Column::by_value("AUTO", 6, |value| {
if value == "yes" {
Palette::warn()
} else {
Palette::label()
}
}),
];
let rows: Vec<Vec<String>> = components
.iter()
.map(|component| {
vec![
component.name.clone(),
component.id.clone(),
component.queue.clone().unwrap_or_else(|| "-".to_owned()),
component.lead.clone().unwrap_or_else(|| "-".to_owned()),
if component.assign_auto { "yes" } else { "no" }.to_owned(),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&match scope {
Some(queue) => counted(queue, components.len(), ctx),
None => tally(components.len(), Some(components.len() as u64), None, ctx),
});
out
}
#[must_use]
pub fn versions(queue: &str, versions: &[crate::api::Version], ctx: &Context) -> String {
let columns = [
Column::whole("ID", 10, Palette::key()),
Column::new("NAME", 28, anstyle::Style::new()),
Column::by_value("STATE", 10, |state| match state {
"open" => Palette::ok(),
_ => Palette::label(),
}),
Column::whole("DUE", 12, anstyle::Style::new()),
];
let rows: Vec<Vec<String>> = versions
.iter()
.map(|version| {
vec![
version.id.clone(),
version.name.clone(),
version.state.to_owned(),
version.due.clone().unwrap_or_else(|| "-".to_owned()),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&counted(queue, versions.len(), ctx));
out
}
#[must_use]
pub fn tags(queue: &str, tags: &[String], ctx: &Context) -> String {
let columns = [Column::whole("TAG", 30, Palette::key())];
let rows: Vec<Vec<String>> = tags.iter().map(|tag| vec![tag.clone()]).collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&counted(queue, tags.len(), ctx));
out
}
fn counted(queue: &str, count: usize, ctx: &Context) -> String {
let paint = ctx.painter();
format!(
"{}\n",
paint.paint(
&format!("shown {count} of {count} for {queue}"),
Palette::label()
)
)
}
#[must_use]
pub fn automation(queue: &str, automation: &Automation, ctx: &Context) -> String {
let mut out = String::with_capacity(320);
out.push_str(¯os_section(queue, automation, ctx));
out.push('\n');
out.push_str(&autoactions_section(queue, automation, ctx));
out.push('\n');
out.push_str(&triggers_section(queue, automation, ctx));
out
}
fn heading(text: &str, ctx: &Context) -> String {
format!("{}\n", ctx.painter().paint(text, Palette::heading()))
}
fn macros_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
let rows: Vec<Vec<String>> = automation
.macros
.iter()
.map(|entry| {
vec![
entry.id.clone(),
entry.name.clone(),
actions(&entry.updates),
if entry.body.is_some() { "yes" } else { "no" }.to_owned(),
]
})
.collect();
let mut out = heading("macros", ctx);
out.push_str(&render(
&[
Column::whole("ID", 8, Palette::key()),
Column::new("NAME", 30, anstyle::Style::new()),
Column::new("SETS", 24, anstyle::Style::new()),
Column::whole("COMMENTS", 8, Palette::label()),
],
&rows,
ctx,
));
out.push_str(&closing(
queue,
"macros",
rows.len(),
&automation.unreadable,
ctx,
));
out
}
fn autoactions_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
let rows: Vec<Vec<String>> = automation
.autoactions
.iter()
.map(|entry| {
vec![
entry.id.clone(),
entry.name.clone(),
active(entry.active).to_owned(),
entry
.interval
.map_or_else(|| "-".to_owned(), |seconds| format!("{seconds}s")),
actions(&entry.actions),
]
})
.collect();
let mut out = heading("autoactions", ctx);
out.push_str(&render(
&[
Column::whole("ID", 8, Palette::key()),
Column::new("NAME", 28, anstyle::Style::new()),
Column::by_value("ACTIVE", 8, state_colour),
Column::whole("EVERY", 10, anstyle::Style::new()),
Column::new("DOES", 22, anstyle::Style::new()),
],
&rows,
ctx,
));
out.push_str(&closing(
queue,
"autoactions",
rows.len(),
&automation.unreadable,
ctx,
));
out
}
fn triggers_section(queue: &str, automation: &Automation, ctx: &Context) -> String {
let rows: Vec<Vec<String>> = automation
.triggers
.iter()
.map(|entry| {
vec![
entry.id.clone(),
entry.name.clone(),
active(entry.active).to_owned(),
entry.conditions.to_string(),
actions(&entry.actions),
]
})
.collect();
let mut out = heading("triggers", ctx);
out.push_str(&render(
&[
Column::whole("ID", 8, Palette::key()),
Column::new("NAME", 28, anstyle::Style::new()),
Column::by_value("ACTIVE", 8, state_colour),
Column::whole("WHEN", 10, Palette::label()),
Column::new("DOES", 22, anstyle::Style::new()),
],
&rows,
ctx,
));
out.push_str(&closing(
queue,
"triggers",
rows.len(),
&automation.unreadable,
ctx,
));
out
}
fn closing(
queue: &str,
section: &str,
count: usize,
unreadable: &[Unreadable],
ctx: &Context,
) -> String {
match unreadable.iter().find(|refusal| refusal.section == section) {
Some(refusal) => {
let paint = ctx.painter();
format!(
"{}\n",
paint.paint(
&format!("not readable — {}", refusal.reason),
Palette::warn()
)
)
}
None => counted(queue, count, ctx),
}
}
#[must_use]
pub fn access(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
let mut out = String::with_capacity(320);
out.push_str(&permissions_section(queue, access, ctx));
out.push('\n');
out.push_str(&access_section(queue, access, ctx));
out
}
fn permissions_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
let rows: Vec<Vec<String>> = access
.permissions
.iter()
.map(|entry| {
vec![
entry.operation.clone(),
granted_to(entry),
holders(&entry.users),
]
})
.collect();
let mut out = heading("permissions", ctx);
out.push_str(&render(
&[
Column::whole("OPERATION", 14, Palette::key()),
Column::new("ROLES", 34, anstyle::Style::new()),
Column::new("USERS", 28, anstyle::Style::new()),
],
&rows,
ctx,
));
out.push_str(&closing(
queue,
"permissions",
rows.len(),
&access.unreadable,
ctx,
));
if rows.iter().any(|row| row[1] != "-") {
let paint = ctx.painter();
let _ = writeln!(
out,
"{}",
paint.paint(
"a role is decided per issue: `assignee` is whoever that issue names",
Palette::label()
)
);
}
out
}
fn access_section(queue: &str, access: &QueueAccess, ctx: &Context) -> String {
let rows: Vec<Vec<String>> = access
.access
.iter()
.map(|entry| {
vec![
entry.operation.clone(),
holds(entry, access.you.as_deref()).to_owned(),
holders(&entry.users),
]
})
.collect();
let mut out = heading("access", ctx);
out.push_str(&render(
&[
Column::whole("OPERATION", 14, Palette::key()),
Column::by_value("YOU", 5, |held| match held {
"yes" => Palette::ok(),
"no" => Palette::warn(),
_ => Palette::label(),
}),
Column::new("USERS", 44, anstyle::Style::new()),
],
&rows,
ctx,
));
out.push_str(&closing(
queue,
"access",
rows.len(),
&access.unreadable,
ctx,
));
out
}
fn holds(permission: &Permission, you: Option<&str>) -> &'static str {
match you {
Some(id) if permission.users.iter().any(|holder| holder.id == id) => "yes",
Some(_) => "no",
None => "?",
}
}
fn granted_to(permission: &Permission) -> String {
let mut names: Vec<String> = permission
.groups
.iter()
.map(|group| format!("group:{}", group.display))
.collect();
names.extend(permission.roles.iter().map(|role| role.id.clone()));
if names.is_empty() {
"-".to_owned()
} else {
names.join(", ")
}
}
fn holders(list: &[Holder]) -> String {
if list.is_empty() {
return "-".to_owned();
}
let names: Vec<&str> = list.iter().map(|holder| holder.display.as_str()).collect();
format!("{}: {}", list.len(), names.join(", "))
}
fn active(flag: bool) -> &'static str {
if flag { "on" } else { "off" }
}
fn state_colour(state: &str) -> anstyle::Style {
if state == "on" {
Palette::ok()
} else {
Palette::label()
}
}
fn actions(actions: &[String]) -> String {
if actions.is_empty() {
"-".to_owned()
} else {
actions.join(", ")
}
}
#[must_use]
pub fn settings(queue: &QueueSettings, ctx: &Context) -> String {
let mut out = String::with_capacity(200);
let paint = ctx.painter();
let label = |text: &str| paint.paint(text, Palette::label());
let _ = writeln!(
out,
"{} {}",
paint.paint(&queue.key, Palette::key()),
queue.name
);
let _ = writeln!(
out,
"{} {} {} {} {} {}",
label("lead:"),
queue.lead.as_deref().unwrap_or("-"),
label("default type:"),
queue.default_type.as_deref().unwrap_or("-"),
label("default priority:"),
queue.default_priority.as_deref().unwrap_or("-"),
);
out
}
#[must_use]
pub fn templates(templates: &[Template], ctx: &Context) -> String {
let columns = [
Column::whole("ID", 12, Palette::key()),
Column::new("NAME", 36, anstyle::Style::new()),
Column::whole("QUEUE", 12, anstyle::Style::new()),
Column::new("AUTHOR", 18, Palette::label()),
];
let rows: Vec<Vec<String>> = templates
.iter()
.map(|template| {
vec![
template.id.clone(),
template.name.clone(),
template.queue.as_deref().unwrap_or("-").to_owned(),
template.author.as_deref().unwrap_or("-").to_owned(),
]
})
.collect();
let mut out = render(&columns, &rows, ctx);
out.push_str(&tally(
templates.len(),
Some(templates.len() as u64),
None,
ctx,
));
out
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
fn ctx() -> Context {
Context {
format: crate::render::Format::Text,
audience: crate::render::Audience::Machine,
description_lines: Some(10),
extra_fields: Vec::new(),
width: 80,
images: false,
inline: crate::render::image::Inline::default(),
}
}
#[test]
fn field_listing_marks_custom_fields_and_counts_them() {
let listing = fields(
&[
QueueField {
key: "summary".to_owned(),
name: "Summary".to_owned(),
field_type: "string".to_owned(),
system: true,
},
QueueField {
key: "storyPoints".to_owned(),
name: "Story points".to_owned(),
field_type: "number".to_owned(),
system: false,
},
],
&ctx(),
);
assert!(listing.contains("summary"));
assert!(listing.contains("system"));
assert!(listing.contains("storyPoints"));
assert!(listing.ends_with("shown 2 of 2 (1 custom)\n"));
}
}