use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::io::IsTerminal;
use anyhow::{Result, bail};
use clap::Args;
use clap::builder::styling::Style;
use regex::Regex;
use strum::{EnumCount, EnumProperty, IntoEnumIterator, VariantArray};
use bob::db::{Database, PackageStatusRow};
use bob::{
ColumnAlign, Config, PackageState, PackageStateAlias, PackageStateKind, Scheduler, WrkObjKind,
parse_status_filter,
};
use super::util::pkg_pattern;
use super::{Col, Formatter, OutputFormat, SortKey, parse_sort_specs, sort_indexed_rows};
#[derive(Clone, Copy, strum::EnumProperty, strum::IntoStaticStr, strum::VariantArray)]
#[strum(serialize_all = "snake_case")]
enum StatusCol {
#[strum(props(default = "true", max = "40", desc = "Package name"))]
Pkgname,
#[strum(props(max = "35", desc = "Package path (category/name)"))]
Pkgpath,
#[strum(props(default = "true", desc = "Current build status"))]
Status,
#[strum(props(default = "true", desc = "Status detail or reason"))]
Reason,
#[strum(props(desc = "MULTI_VERSION package build variables"))]
MultiVersion,
#[strum(props(max = "6", align = "right", desc = "Number of dependent packages"))]
Deps,
#[strum(props(max = "8", align = "right", desc = "Scheduler priority order"))]
Priority,
#[strum(props(max = "8", align = "right", desc = "Previous build CPU time"))]
Cpu,
#[strum(props(
max = "9",
align = "right",
desc = "MAKE_JOBS used by current build, otherwise predicted allocation"
))]
MakeJobs,
#[strum(props(
max = "9",
desc = "WRKOBJDIR used by current build, otherwise predicted routing"
))]
Wrkobjdir,
#[strum(props(
max = "10",
align = "right",
desc = "WRKDIR size at end of current build"
))]
DiskUsage,
}
impl bob::ColumnAlign for StatusCol {}
impl StatusCol {
fn max_width(self) -> usize {
self.get_str("max")
.and_then(|s| s.parse().ok())
.unwrap_or(usize::MAX)
}
fn is_default(self) -> bool {
self.get_str("default").is_some()
}
fn desc(self) -> &'static str {
self.get_str("desc").expect("desc prop")
}
fn find(name: &str) -> Option<Self> {
Self::VARIANTS
.iter()
.find(|v| <&str>::from(*v) == name)
.copied()
}
}
const STATUS_DISPLAY_ORDER: [PackageStateKind; PackageStateKind::COUNT] = {
use PackageStateKind::*;
[
Pending,
UpToDate,
Success,
Failed,
PreSkipped,
PreFailed,
Unresolved,
IndirectFailed,
IndirectPreSkipped,
IndirectPreFailed,
IndirectUnresolved,
]
};
fn longest<'a, I: IntoIterator<Item = &'a str>>(names: I) -> usize {
names.into_iter().map(str::len).fold(0, usize::max)
}
fn styled() -> bool {
std::io::stdout().is_terminal()
}
fn header_style() -> Style {
if styled() {
Style::new().bold().underline()
} else {
Style::new()
}
}
fn literal_style() -> Style {
if styled() {
Style::new().bold()
} else {
Style::new()
}
}
fn write_item(out: &mut String, name: &str, desc: &str, name_pad: usize, literal: Style) {
let padding = " ".repeat(name_pad.saturating_sub(name.len()) + 1);
let _ = writeln!(out, "- {literal}{name}{literal:#}:{padding}{desc}");
}
fn columns_section() -> String {
let literal = literal_style();
let width = longest(StatusCol::VARIANTS.iter().map(<&str>::from));
let mut out = String::from("Possible values:\n");
for c in StatusCol::VARIANTS {
write_item(&mut out, <&str>::from(c), c.desc(), width, literal);
}
out
}
fn status_section() -> String {
let literal = literal_style();
let width = longest(
PackageStateKind::iter()
.map(<&str>::from)
.chain(PackageStateAlias::iter().map(<&str>::from)),
);
let mut out = String::from("Possible values:\n");
for &k in &STATUS_DISPLAY_ORDER {
write_item(&mut out, <&str>::from(k), k.desc(), width, literal);
}
for a in PackageStateAlias::iter() {
let alias_desc = a.desc();
let desc = format!("{alias_desc} (alias)");
write_item(&mut out, <&str>::from(a), &desc, width, literal);
}
out
}
fn examples_section() -> String {
let header = header_style();
let pending: &str = PackageStateKind::Pending.into();
let failed: &str = PackageStateKind::Failed.into();
let skipped: &str = PackageStateAlias::Skipped.into();
let pre_skipped: &str = PackageStateKind::PreSkipped.into();
let pre_failed: &str = PackageStateKind::PreFailed.into();
let examples = [
(
"bob status".into(),
format!("Show {pending}/{failed} packages"),
),
("bob status -a".into(), "Show all packages".into()),
(
format!("bob status -s {skipped}"),
format!("Show {pre_skipped} and {pre_failed}"),
),
(
"bob status ^mutt- meta-pkgs/bulk".into(),
"Show multiple package or pkgpath matches".into(),
),
(
format!("bob status -Ho pkgpath -s {pending}"),
format!("Show all {pending} pkgpath builds"),
),
];
let ex_width = examples
.iter()
.map(|(cmd, _)| cmd.len())
.fold(0, usize::max);
let mut out = format!("{header}Examples:{header:#}\n");
for (cmd, desc) in &examples {
let _ = writeln!(out, " {cmd:w$} {desc}", w = ex_width);
}
out
}
fn columns_long_help() -> String {
format!(
"Columns to display (comma-separated; use -l to see all)\n\n{}",
columns_section().trim_end()
)
}
fn status_long_help() -> String {
format!(
"Filter by status (repeatable or comma-separated)\n\n{}",
status_section().trim_end()
)
}
pub fn after_help() -> String {
examples_section()
}
#[derive(Debug, Args)]
pub struct StatusArgs {
#[arg(short, long)]
all: bool,
#[arg(short = 'H')]
no_header: bool,
#[arg(short = 'l', long)]
long: bool,
#[arg(short = 'f', long, value_enum, default_value_t = OutputFormat::Table)]
format: OutputFormat,
#[arg(short = 'o', long_help = columns_long_help(), value_delimiter = ',')]
columns: Option<Vec<String>>,
#[arg(
short = 's',
long = "status",
long_help = status_long_help(),
value_parser = parse_status_filter,
value_delimiter = ',',
)]
statuses: Vec<Vec<PackageStateKind>>,
#[arg(short = 'S', long, value_delimiter = ',', allow_hyphen_values = true)]
sort: Option<Vec<String>>,
packages: Vec<String>,
}
pub fn run(db: &Database, config: &Config, args: StatusArgs) -> Result<()> {
if db.count_packages()? == 0 {
bail!("No packages in database. Run 'bob scan' first.");
}
let statuses: HashSet<PackageStateKind> = args.statuses.iter().flatten().copied().collect();
print_build_status(
db,
config,
&statuses,
args.columns.as_deref(),
args.no_header,
args.long,
args.format,
&args.packages,
args.all,
args.sort.as_deref(),
)
}
#[allow(clippy::too_many_arguments)]
fn print_build_status(
db: &Database,
config: &Config,
statuses: &HashSet<PackageStateKind>,
columns: Option<&[String]>,
no_header: bool,
long: bool,
format: OutputFormat,
pkg_filters: &[String],
show_all: bool,
sort: Option<&[String]>,
) -> Result<()> {
let all_names: Vec<&str> = StatusCol::VARIANTS.iter().map(|v| v.into()).collect();
let cols: Vec<&str> = if columns.is_some() {
columns
.map(|c| c.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
} else if long {
all_names.clone()
} else {
StatusCol::VARIANTS
.iter()
.filter(|v| v.is_default())
.map(|v| v.into())
.collect()
};
for col in &cols {
if !all_names.contains(col) {
bail!(
"Unknown column '{}'. Valid columns: {}",
col,
all_names.join(", ")
);
}
}
let sort_specs: Vec<(StatusCol, bool)> = match sort {
Some(values) => parse_sort_specs(values, StatusCol::find, &all_names)?,
None => Vec::new(),
};
let pkg_patterns: Vec<Regex> = pkg_filters
.iter()
.map(String::as_str)
.map(pkg_pattern)
.collect::<Result<Vec<_>>>()?;
let status_rows = db.get_all_package_status()?;
let status_map: HashMap<&str, &PackageStatusRow> = status_rows
.iter()
.map(|r| (r.pkgname.as_str(), r))
.collect();
let mut sched = Scheduler::new(db)?;
if let Some(jobs) = config.jobs() {
sched.set_allocator(bob::makejobs::Allocator::new(config.build_threads(), jobs));
sched.allocate_all();
}
let need_history = cols
.iter()
.any(|c| matches!(*c, "wrkobjdir" | "make_jobs" | "disk_usage"));
let history = if need_history {
match db.build_id() {
Ok(id) => db.build_history_by_pkg_all(Some(&id)),
Err(_) => HashMap::new(),
}
} else {
HashMap::new()
};
let predicted_wrkobjdir: Option<WrkObjKind> = config.wrkobjdir().and_then(|w| w.route(None));
let predicted_wrkobjdir_for = |pkgpath: &str| -> Option<WrkObjKind> {
let w = config.wrkobjdir()?;
if w.always_disk.iter().any(|p| p == pkgpath) {
return w.disk.clone().map(WrkObjKind::Disk);
}
predicted_wrkobjdir.clone()
};
let get_status = |pkg: &PackageStatusRow| -> (PackageStateKind, String) {
if let Some(state) = pkg
.build_outcome
.and_then(|id| PackageState::from_db(id, pkg.outcome_detail.clone()))
{
let reason = state.detail().map(String::from).unwrap_or_default();
(state.kind(), reason)
} else if let Some(reason) = &pkg.build_reason {
(PackageStateKind::Pending, reason.clone())
} else if let Some(reason) = &pkg.pkg_fail_reason {
(
PackageStateKind::PreFailed,
format!("PKG_FAIL_REASON: {}", reason),
)
} else if let Some(reason) = &pkg.pkg_skip_reason {
(
PackageStateKind::PreSkipped,
format!("PKG_SKIP_REASON: {}", reason),
)
} else {
(PackageStateKind::Pending, String::new())
}
};
let matches_status = |kind: PackageStateKind| -> bool {
if !statuses.is_empty() {
return statuses.contains(&kind);
}
if show_all || !pkg_patterns.is_empty() {
return true;
}
!matches!(kind, PackageStateKind::Success | PackageStateKind::UpToDate)
};
let mut indexed_rows: Vec<(Vec<SortKey>, Vec<String>)> = Vec::new();
for sp in sched.iter() {
let Some(pkg) = status_map.get(sp.pkg.pkgname()) else {
continue;
};
if !pkg_patterns.is_empty()
&& !pkg_patterns
.iter()
.any(|re| re.is_match(&pkg.pkgname) || re.is_match(&pkg.pkg_location))
{
continue;
}
let (kind, reason) = get_status(pkg);
if !matches_status(kind) {
continue;
}
let multi_version = pkg
.multi_version
.as_deref()
.and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
.map(|v| v.join(" "))
.unwrap_or_default();
let dash = || "-".to_string();
let hist = history.get(sp.pkg.pkgbase());
let actual_wrkobjdir = hist.and_then(|h| h.wrkobjdir.clone());
let actual_make_jobs = hist.and_then(|h| h.make_jobs);
let actual_disk_usage = hist.and_then(|h| h.disk_usage);
let need_wrkobjdir = cols.contains(&"wrkobjdir")
|| sort_specs
.iter()
.any(|(c, _)| matches!(c, StatusCol::Wrkobjdir));
let resolved_wrkobjdir: Option<String> = if need_wrkobjdir {
actual_wrkobjdir
.clone()
.or_else(|| predicted_wrkobjdir_for(&pkg.pkg_location).map(|k| k.to_string()))
} else {
None
};
let resolved_make_jobs: Option<u32> =
actual_make_jobs.or_else(|| sp.make_jobs.allocated().map(|n| n as u32));
let row: Vec<String> = cols
.iter()
.map(|&col| match col {
"pkgname" => pkg.pkgname.clone(),
"pkgpath" => pkg.pkg_location.clone(),
"status" => <&str>::from(kind).to_string(),
"reason" => reason.clone(),
"multi_version" => multi_version.clone(),
"deps" => sp.dep_count.to_string(),
"priority" => sp.total_pbulk_weight.to_string(),
"cpu" => {
if sp.cpu_time > 0 {
bob::format_duration(sp.cpu_time)
} else {
dash()
}
}
"wrkobjdir" => resolved_wrkobjdir.clone().unwrap_or_else(dash),
"make_jobs" => resolved_make_jobs
.map(|n| n.to_string())
.unwrap_or_else(dash),
"disk_usage" => actual_disk_usage.map(bob::format_size).unwrap_or_else(dash),
_ => String::new(),
})
.collect();
let sort_keys: Vec<SortKey> = sort_specs
.iter()
.map(|(c, _)| match c {
StatusCol::Pkgname => SortKey::Str(pkg.pkgname.clone()),
StatusCol::Pkgpath => SortKey::Str(pkg.pkg_location.clone()),
StatusCol::Status => SortKey::Idx(kind as usize),
StatusCol::Reason => SortKey::Str(reason.clone()),
StatusCol::MultiVersion => SortKey::Str(multi_version.clone()),
StatusCol::Deps => SortKey::Num(Some(sp.dep_count as u64)),
StatusCol::Priority => SortKey::Num(Some(sp.total_pbulk_weight as u64)),
StatusCol::Cpu => SortKey::Num(if sp.cpu_time > 0 {
Some(sp.cpu_time)
} else {
None
}),
StatusCol::MakeJobs => SortKey::Num(resolved_make_jobs.map(u64::from)),
StatusCol::Wrkobjdir => SortKey::OptStr(resolved_wrkobjdir.clone()),
StatusCol::DiskUsage => SortKey::Num(actual_disk_usage),
})
.collect();
indexed_rows.push((sort_keys, row));
}
if indexed_rows.is_empty() {
if !statuses.is_empty() || !pkg_filters.is_empty() {
bail!("No packages match the criteria");
}
return Ok(());
}
if !sort_specs.is_empty() {
let descs: Vec<bool> = sort_specs.iter().map(|(_, d)| *d).collect();
sort_indexed_rows(&mut indexed_rows, &descs);
}
let rows: Vec<Vec<String>> = indexed_rows.into_iter().map(|(_, r)| r).collect();
let fmt_cols: Vec<Col> = cols
.iter()
.map(|&name| {
let sc = StatusCol::find(name).expect("column already validated");
Col::new(name, sc.align()).max(sc.max_width())
})
.collect();
let mut fmt = Formatter::new(fmt_cols);
for row in rows {
fmt.push(row);
}
fmt.print(format, no_header);
Ok(())
}