use colored::Colorize;
use tabled::{Table, Tabled};
use super::formatting::{opt_yes_no, print_field, print_formatted, print_optional_field};
use crate::types::{BugzillaUser, OutputFormat, WhoamiResponse};
#[derive(Tabled)]
struct UserRow {
#[tabled(rename = "ID")]
id: u64,
#[tabled(rename = "NAME")]
name: String,
#[tabled(rename = "REAL NAME")]
real_name: String,
#[tabled(rename = "EMAIL")]
email: String,
}
#[derive(Tabled)]
struct DetailedUserRow {
#[tabled(rename = "ID")]
id: u64,
#[tabled(rename = "NAME")]
name: String,
#[tabled(rename = "REAL NAME")]
real_name: String,
#[tabled(rename = "EMAIL")]
email: String,
#[tabled(rename = "CAN LOGIN")]
can_login: String,
#[tabled(rename = "GROUPS")]
groups: String,
}
fn basic_row(user: &BugzillaUser) -> UserRow {
UserRow {
id: user.id,
name: user.name.clone(),
real_name: user.real_name.clone().unwrap_or_default(),
email: user.email.clone().unwrap_or_default(),
}
}
fn detailed_row(user: &BugzillaUser) -> DetailedUserRow {
DetailedUserRow {
id: user.id,
name: user.name.clone(),
real_name: user.real_name.clone().unwrap_or_default(),
email: user.email.clone().unwrap_or_default(),
can_login: opt_yes_no(user.can_login).into(),
groups: if user.groups.is_empty() {
"-".into()
} else {
user.groups
.iter()
.map(|g| g.name.as_str())
.collect::<Vec<_>>()
.join(", ")
},
}
}
#[expect(clippy::print_stdout)]
pub fn print_users(users: &[BugzillaUser], format: OutputFormat) {
print_formatted(users, format, |users| {
if users.is_empty() {
println!("No users found.");
return;
}
let rows: Vec<UserRow> = users.iter().map(basic_row).collect();
println!("{}", Table::new(rows));
});
}
#[expect(clippy::print_stdout)]
pub fn print_users_detailed(users: &[BugzillaUser], format: OutputFormat) {
print_formatted(users, format, |users| {
if users.is_empty() {
println!("No users found.");
return;
}
let rows: Vec<DetailedUserRow> = users.iter().map(detailed_row).collect();
println!("{}", Table::new(rows));
});
}
#[expect(clippy::print_stdout)]
pub fn print_whoami(whoami: &WhoamiResponse, format: OutputFormat) {
print_formatted(whoami, format, |whoami| {
println!("{} {}", "User".bold(), whoami.name.bold());
print_optional_field("Name", whoami.real_name.as_deref());
print_optional_field("Login", whoami.login.as_deref());
print_field("ID", &whoami.id.to_string());
});
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::types::{BugzillaUser, UserGroup, WhoamiResponse};
use tabled::Table;
fn make_user(id: u64, name: &str, can_login: Option<bool>, groups: Vec<&str>) -> BugzillaUser {
BugzillaUser {
id,
name: name.into(),
real_name: Some(format!("{name} Real")),
email: Some(format!("{name}@example.com")),
groups: groups
.into_iter()
.map(|g| UserGroup {
id: 1,
name: g.into(),
description: String::new(),
})
.collect(),
can_login,
}
}
fn make_whoami() -> WhoamiResponse {
WhoamiResponse {
id: 42,
name: "testuser".into(),
real_name: Some("Test User".into()),
login: Some("testuser@example.com".into()),
}
}
#[test]
fn user_row_excludes_detail_columns() {
let user = make_user(1, "alice", Some(true), vec!["admin"]);
let row = UserRow {
id: user.id,
name: user.name.clone(),
real_name: user.real_name.clone().unwrap_or_default(),
email: user.email.clone().unwrap_or_default(),
};
let table = Table::new(vec![row]).to_string();
assert!(table.contains("ID"));
assert!(table.contains("NAME"));
assert!(table.contains("EMAIL"));
assert!(!table.contains("CAN LOGIN"));
assert!(!table.contains("GROUPS"));
}
#[test]
fn detailed_user_row_includes_groups_and_login() {
let users = [
make_user(1, "alice", Some(true), vec!["admin", "dev"]),
make_user(2, "bob", Some(false), vec![]),
make_user(3, "carol", None, vec!["testers"]),
];
let rows: Vec<DetailedUserRow> = users.iter().map(detailed_row).collect();
let table = Table::new(rows).to_string();
assert!(table.contains("CAN LOGIN"));
assert!(table.contains("GROUPS"));
assert!(table.contains("Yes"));
assert!(table.contains("No"));
assert!(table.contains("admin, dev"));
assert!(table.contains('-'));
let lines: Vec<&str> = table.lines().collect();
let carol_line = lines.iter().find(|l| l.contains("carol")).unwrap();
assert!(carol_line.contains("testers"));
assert!(carol_line.contains('-'));
}
#[test]
fn print_users_json_includes_can_login() {
let users = vec![make_user(1, "alice", Some(true), vec!["admin"])];
let json = serde_json::to_string_pretty(&users).unwrap();
assert!(json.contains("\"can_login\": true"));
assert!(json.contains("\"groups\""));
}
#[test]
fn print_whoami_json() {
let whoami = make_whoami();
let json = serde_json::to_string_pretty(&whoami).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["id"], 42);
assert_eq!(parsed["name"], "testuser");
assert_eq!(parsed["real_name"], "Test User");
assert_eq!(parsed["login"], "testuser@example.com");
}
#[test]
fn print_whoami_json_minimal() {
let whoami = WhoamiResponse {
id: 1,
name: "bot".into(),
real_name: None,
login: None,
};
let json = serde_json::to_string_pretty(&whoami).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["id"], 1);
assert!(parsed["real_name"].is_null());
assert!(parsed["login"].is_null());
}
#[test]
fn print_users_json_empty() {
let users: Vec<BugzillaUser> = vec![];
let json = serde_json::to_string_pretty(&users).unwrap();
assert_eq!(json, "[]");
}
#[test]
fn print_users_json_includes_all_fields() {
let users = vec![make_user(1, "alice", Some(true), vec!["admin"])];
let json = serde_json::to_string_pretty(&users).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed[0]["id"], 1);
assert_eq!(parsed[0]["name"], "alice");
assert_eq!(parsed[0]["real_name"], "alice Real");
assert_eq!(parsed[0]["email"], "alice@example.com");
assert_eq!(parsed[0]["can_login"], true);
assert_eq!(parsed[0]["groups"][0]["name"], "admin");
}
}