use std::collections::HashSet;
use std::io::Write;
use colored::Colorize;
use serde_json::Value;
use tabled::builder::Builder;
use crate::output::formatting::{
colorize_status, shorten_email, truncate, write_divider, write_field, write_formatted,
write_json, write_list_field, write_optional_field, SUMMARY_TRUNCATE_WIDTH,
};
use crate::types::{Bug, HistoryEntry, OutputFormat};
#[derive(Debug, Clone, Copy, Default)]
pub struct ColumnSpec<'a> {
pub include: Option<&'a str>,
pub exclude: Option<&'a str>,
}
impl<'a> ColumnSpec<'a> {
pub fn new(include: Option<&'a str>, exclude: Option<&'a str>) -> Self {
ColumnSpec { include, exclude }
}
}
struct BugColumn {
aliases: &'static [&'static str],
header: &'static str,
render: fn(&Bug) -> String,
}
impl BugColumn {
fn canonical(&self) -> &'static str {
self.aliases[0]
}
}
const DEFAULT_COLUMNS: &[&str] = &["id", "status", "priority", "assignee", "summary"];
const COLUMNS: &[BugColumn] = &[
BugColumn {
aliases: &["id"],
header: "ID",
render: |b| b.id.to_string(),
},
BugColumn {
aliases: &["status"],
header: "STATUS",
render: |b| b.status.clone(),
},
BugColumn {
aliases: &["priority"],
header: "PRIORITY",
render: |b| b.priority.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["assigned_to", "assignee"],
header: "ASSIGNEE",
render: |b| shorten_email(b.assigned_to.as_deref().unwrap_or("")),
},
BugColumn {
aliases: &["summary"],
header: "SUMMARY",
render: |b| truncate(&b.summary, SUMMARY_TRUNCATE_WIDTH),
},
BugColumn {
aliases: &["severity"],
header: "SEVERITY",
render: |b| b.severity.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["product"],
header: "PRODUCT",
render: |b| b.product.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["component"],
header: "COMPONENT",
render: |b| b.component.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["resolution"],
header: "RESOLUTION",
render: |b| b.resolution.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["version"],
header: "VERSION",
render: |b| b.version.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["creator", "reporter"],
header: "CREATOR",
render: |b| b.creator.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["creation_time", "created"],
header: "CREATED",
render: |b| b.creation_time.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["last_change_time", "updated"],
header: "UPDATED",
render: |b| b.last_change_time.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["url"],
header: "URL",
render: |b| b.url.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["whiteboard"],
header: "WHITEBOARD",
render: |b| b.whiteboard.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["op_sys"],
header: "OP_SYS",
render: |b| b.op_sys.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["rep_platform", "platform"],
header: "PLATFORM",
render: |b| b.rep_platform.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["deadline"],
header: "DEADLINE",
render: |b| b.deadline.clone().unwrap_or_default(),
},
BugColumn {
aliases: &["keywords"],
header: "KEYWORDS",
render: |b| b.keywords.join(", "),
},
BugColumn {
aliases: &["blocks"],
header: "BLOCKS",
render: |b| join_ids(&b.blocks),
},
BugColumn {
aliases: &["depends_on"],
header: "DEPENDS_ON",
render: |b| join_ids(&b.depends_on),
},
BugColumn {
aliases: &["cc"],
header: "CC",
render: |b| b.cc.join(", "),
},
BugColumn {
aliases: &["dupe_of"],
header: "DUPE_OF",
render: |b| b.dupe_of.map(|id| id.to_string()).unwrap_or_default(),
},
];
#[derive(Clone, Copy)]
enum SelectedBugField<'a> {
BuiltIn(&'static BugColumn),
Custom(&'a str),
}
impl<'a> SelectedBugField<'a> {
fn key(self) -> &'a str {
match self {
SelectedBugField::BuiltIn(column) => column.canonical(),
SelectedBugField::Custom(name) => name,
}
}
fn header(self) -> String {
match self {
SelectedBugField::BuiltIn(column) => column.header.to_string(),
SelectedBugField::Custom(name) => name.to_ascii_uppercase(),
}
}
fn render(self, bug: &Bug) -> String {
match self {
SelectedBugField::BuiltIn(column) => (column.render)(bug),
SelectedBugField::Custom(name) => render_custom_value(bug.custom_fields.get(name)),
}
}
}
struct FieldPartition<'a> {
ordered: Vec<SelectedBugField<'a>>,
custom: Vec<&'a str>,
unknown: Vec<&'a str>,
}
fn join_ids(ids: &[u64]) -> String {
ids.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ")
}
fn resolve_bug_column(token: &str) -> Option<&'static BugColumn> {
let token = token.trim().to_ascii_lowercase();
COLUMNS.iter().find(|c| c.aliases.contains(&token.as_str()))
}
pub fn canonical_field_list(fields: Option<&str>) -> Option<String> {
let fields = fields?;
let mut out: Vec<&str> = Vec::new();
for token in fields.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
match resolve_bug_column(token) {
Some(col) => out.push(col.canonical()),
None => out.push(token),
}
}
if out.is_empty() {
None
} else {
Some(out.join(","))
}
}
fn default_columns() -> Vec<&'static BugColumn> {
DEFAULT_COLUMNS
.iter()
.filter_map(|name| resolve_bug_column(name))
.collect()
}
fn default_selected_columns() -> Vec<SelectedBugField<'static>> {
default_columns()
.into_iter()
.map(SelectedBugField::BuiltIn)
.collect()
}
fn is_custom_field(token: &str) -> bool {
token.starts_with("cf_")
}
fn partition_include(list: &str) -> FieldPartition<'_> {
let mut partition = FieldPartition {
ordered: Vec::new(),
custom: Vec::new(),
unknown: Vec::new(),
};
let mut seen = HashSet::new();
for token in list.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
match resolve_bug_column(token) {
Some(column) => {
if seen.insert(column.canonical()) {
partition.ordered.push(SelectedBugField::BuiltIn(column));
}
}
None if is_custom_field(token) => {
if seen.insert(token) {
partition.ordered.push(SelectedBugField::Custom(token));
partition.custom.push(token);
}
}
None => partition.unknown.push(token),
}
}
partition
}
fn selected_keys<'a>(fields: &[SelectedBugField<'a>]) -> HashSet<&'a str> {
fields.iter().map(|field| (*field).key()).collect()
}
fn excluded_keys(exclude: Option<&str>) -> HashSet<&str> {
let Some(list) = exclude else {
return HashSet::new();
};
partition_include(list)
.ordered
.iter()
.map(|field| (*field).key())
.collect()
}
fn apply_exclude(fields: &mut Vec<SelectedBugField<'_>>, exclude: Option<&str>) {
if let Some(list) = exclude {
let excluded = excluded_keys(Some(list));
fields.retain(|field| !excluded.contains((*field).key()));
}
}
fn resolve_columns<'a, E: Write + ?Sized>(
spec: ColumnSpec<'a>,
err: &mut E,
) -> Vec<SelectedBugField<'a>> {
let mut columns = match spec.include {
None => default_selected_columns(),
Some(list) => {
let partition = partition_include(list);
if !partition.unknown.is_empty() {
let _ = writeln!(
err,
"warning: ignoring unknown field(s): {}",
partition.unknown.join(", ")
);
}
if partition.ordered.is_empty() {
default_selected_columns()
} else {
partition.ordered
}
}
};
apply_exclude(&mut columns, spec.exclude);
columns
}
pub fn validate_table_columns(spec: ColumnSpec<'_>) -> crate::error::Result<()> {
let mut columns = match spec.include {
None => default_selected_columns(),
Some(list) => {
let partition = partition_include(list);
if partition.ordered.is_empty() {
if partition.unknown.is_empty() {
default_selected_columns()
} else {
return Err(crate::error::BzrError::InputValidation(format!(
"none of the requested fields are known bug fields: {}",
partition.unknown.join(", ")
)));
}
} else {
partition.ordered
}
}
};
apply_exclude(&mut columns, spec.exclude);
if columns.is_empty() {
return Err(crate::error::BzrError::InputValidation(
"--exclude-fields removed every table column; nothing left to display".into(),
));
}
Ok(())
}
fn canonical_excludes(exclude: Option<&str>) -> Vec<&str> {
match exclude {
None => Vec::new(),
Some(list) => partition_include(list)
.ordered
.iter()
.map(|field| (*field).key())
.collect(),
}
}
pub fn bug_to_json(bug: &Bug, spec: ColumnSpec<'_>) -> serde_json::Value {
let mut value = serde_json::to_value(bug).expect("Bug serializes to JSON");
if let serde_json::Value::Object(map) = &mut value {
if let Some(include) = canonical_field_list(spec.include) {
let keep: std::collections::HashSet<&str> = include.split(',').collect();
map.retain(|k, _| keep.contains(k.as_str()));
}
for canonical in canonical_excludes(spec.exclude) {
map.remove(canonical);
}
}
value
}
pub fn bugs_to_json(bugs: &[Bug], spec: ColumnSpec<'_>) -> Vec<serde_json::Value> {
bugs.iter().map(|bug| bug_to_json(bug, spec)).collect()
}
pub fn validate_json_field_selection(spec: ColumnSpec<'_>) -> crate::error::Result<()> {
let mut keys: HashSet<&str> = match spec.include {
Some(list) => {
let partition = partition_include(list);
if partition.ordered.is_empty() && list.split(',').all(|t| t.trim().is_empty()) {
COLUMNS.iter().map(BugColumn::canonical).collect()
} else {
selected_keys(&partition.ordered)
}
}
None => COLUMNS.iter().map(BugColumn::canonical).collect(),
};
for canonical in canonical_excludes(spec.exclude) {
keys.remove(canonical);
}
if keys.is_empty() {
return Err(crate::error::BzrError::InputValidation(
"the field selection leaves no fields to emit; \
adjust --fields / --exclude-fields"
.into(),
));
}
Ok(())
}
pub fn warn_unknown_fields<E: Write + ?Sized>(spec: ColumnSpec<'_>, err: &mut E) {
let Some(list) = spec.include else {
return;
};
let partition = partition_include(list);
if !partition.unknown.is_empty() {
let _ = writeln!(
err,
"warning: ignoring unknown field(s): {}",
partition.unknown.join(", ")
);
}
}
fn field_selected(spec: ColumnSpec<'_>, field: &str) -> bool {
let Some(target) = resolve_bug_column(field) else {
return true;
};
let matches = |list: &str| {
list.split(',')
.filter_map(resolve_bug_column)
.any(|c| c.header == target.header)
};
let included = spec.include.is_none_or(matches);
let excluded = spec.exclude.is_some_and(matches);
included && !excluded
}
fn render_custom_value(value: Option<&Value>) -> String {
match value {
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(value @ (Value::Array(_) | Value::Object(_))) => value.to_string(),
Some(Value::Null) | None => String::new(),
}
}
fn selected_custom_detail_fields(spec: ColumnSpec<'_>) -> Vec<&str> {
let Some(include) = spec.include else {
return Vec::new();
};
let excluded = excluded_keys(spec.exclude);
partition_include(include)
.custom
.into_iter()
.filter(|name| !excluded.contains(name))
.collect()
}
pub fn write_bugs<W: Write + ?Sized, E: Write + ?Sized>(
bugs: &[Bug],
spec: ColumnSpec<'_>,
format: OutputFormat,
out: &mut W,
err: &mut E,
) {
match format {
OutputFormat::Json => write_json(&bugs_to_json(bugs, spec), out),
OutputFormat::Table => {
if bugs.is_empty() {
let _ = writeln!(out, "No bugs found.");
return;
}
let columns = resolve_columns(spec, err);
let mut builder = Builder::default();
builder.push_record(columns.iter().map(|field| (*field).header()));
for bug in bugs {
builder.push_record(columns.iter().map(|field| (*field).render(bug)));
}
let _ = writeln!(out, "{}", builder.build());
}
}
}
pub fn write_bug_detail<W: Write + ?Sized>(
bug: &Bug,
spec: ColumnSpec<'_>,
format: OutputFormat,
out: &mut W,
) {
match format {
OutputFormat::Json => write_json(&bug_to_json(bug, spec), out),
OutputFormat::Table => write_bug_detail_table(bug, spec, out),
}
}
fn write_bug_detail_table(bug: &Bug, spec: ColumnSpec<'_>, out: &mut (impl Write + ?Sized)) {
if field_selected(spec, "summary") {
let _ = writeln!(
out,
"{} #{}\n{}\n",
"Bug".bold(),
bug.id.to_string().bold(),
bug.summary.bold()
);
} else {
let _ = writeln!(out, "{} #{}\n", "Bug".bold(), bug.id.to_string().bold());
}
if field_selected(spec, "status") {
write_field(out, "Status", &colorize_status(&bug.status));
}
if field_selected(spec, "resolution") {
write_optional_field(out, "Resolution", bug.resolution.as_deref());
}
if field_selected(spec, "dupe_of") {
if let Some(dupe_of) = bug.dupe_of {
let _ = writeln!(out, " {:<12} {dupe_of}", "Duplicate of");
}
}
if field_selected(spec, "product") {
write_optional_field(out, "Product", bug.product.as_deref());
}
if field_selected(spec, "component") {
write_optional_field(out, "Component", bug.component.as_deref());
}
if field_selected(spec, "assigned_to") {
write_optional_field(out, "Assignee", bug.assigned_to.as_deref());
}
if field_selected(spec, "priority") {
write_optional_field(out, "Priority", bug.priority.as_deref());
}
if field_selected(spec, "severity") {
write_optional_field(out, "Severity", bug.severity.as_deref());
}
if field_selected(spec, "creator") {
write_optional_field(out, "Creator", bug.creator.as_deref());
}
if field_selected(spec, "creation_time") {
write_optional_field(out, "Created", bug.creation_time.as_deref());
}
if field_selected(spec, "last_change_time") {
write_optional_field(out, "Updated", bug.last_change_time.as_deref());
}
if field_selected(spec, "keywords") {
write_list_field(out, "Keywords", &bug.keywords);
}
if field_selected(spec, "blocks") {
write_id_list_field(out, "Blocks", &bug.blocks);
}
if field_selected(spec, "depends_on") {
write_id_list_field(out, "Depends on", &bug.depends_on);
}
for name in selected_custom_detail_fields(spec) {
write_field(out, name, &render_custom_value(bug.custom_fields.get(name)));
}
}
fn write_id_list_field(out: &mut (impl Write + ?Sized), label: &str, ids: &[u64]) {
if !ids.is_empty() {
let id_str = ids
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(out, " {label:<12} {id_str}");
}
}
pub fn write_history<W: Write + ?Sized>(
history: &[HistoryEntry],
format: OutputFormat,
out: &mut W,
) {
write_formatted(history, format, out, |history, out| {
for entry in history {
let _ = writeln!(
out,
"{} by {} ({})",
"Change".bold(),
entry.who.cyan(),
entry.when,
);
for change in &entry.changes {
let attachment_suffix = change
.attachment_id
.map(|id| format!(" [attachment #{id}]"))
.unwrap_or_default();
let _ = writeln!(out, " {}{attachment_suffix}:", change.field_name.bold());
if !change.removed.is_empty() {
let _ = writeln!(out, " - {}", change.removed.red());
}
if !change.added.is_empty() {
let _ = writeln!(out, " + {}", change.added.green());
}
}
write_divider(out);
}
});
}
#[non_exhaustive]
#[derive(Debug)]
pub enum MultiBugRow {
Ok(Box<Bug>),
Failed { id: String, error: String },
}
pub fn write_multi_bug_view<W: Write + ?Sized>(
rows: &[MultiBugRow],
spec: ColumnSpec<'_>,
out: &mut W,
) {
for (i, row) in rows.iter().enumerate() {
if i > 0 {
write_divider(out);
}
match row {
MultiBugRow::Ok(bug) => write_bug_detail_table(bug, spec, out),
MultiBugRow::Failed { id, error } => write_unavailable_block(id, error, out),
}
}
}
fn write_unavailable_block(id: &str, error: &str, out: &mut (impl Write + ?Sized)) {
let _ = writeln!(
out,
"{} #{} — {}",
"Bug".bold(),
id.bold(),
"UNAVAILABLE".red().bold()
);
let _ = writeln!(out, " Error: {error}");
}
#[cfg(test)]
#[path = "bug_tests.rs"]
mod tests;