use std::collections::{HashMap, HashSet};
use std::io::IsTerminal;
use anyhow::{Result, bail};
use clap::Subcommand;
use crossterm::terminal;
use bob::db::Database;
use bob::try_println;
use bob::{PackageState, PackageStateKind};
use super::util::pkg_pattern;
fn use_color() -> bool {
std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
pub enum TreeOutput {
#[default]
Utf8,
Ascii,
None,
}
#[derive(Debug, Subcommand)]
pub enum ListCmd {
Tree {
#[arg(short, long)]
all: bool,
#[arg(short = 'f', long, value_enum)]
format: Option<TreeOutput>,
#[arg(short, long)]
path: bool,
package: Option<String>,
},
Blockers {
package: String,
#[arg(short, long)]
path: bool,
},
#[command(alias = "broken-by")]
BlockedBy {
package: String,
#[arg(short, long)]
path: bool,
},
}
pub fn run(db: &Database, cmd: ListCmd) -> Result<()> {
if db.count_packages()? == 0 {
bail!("No packages in database. Run 'bob scan' first.");
}
match cmd {
ListCmd::Tree {
all,
format,
path,
package,
} => {
let format = format.unwrap_or(if std::io::stdout().is_terminal() {
TreeOutput::Utf8
} else {
TreeOutput::None
});
print_build_tree(db, path, all, format, package.as_deref())?;
}
ListCmd::Blockers { package, path } => {
let matches = match_packages(db, &package)?;
let multi = matches.len() > 1;
for pkg in matches {
if multi && !try_println(&format!("{} ({}):", pkg.pkgname, pkg.pkg_location)) {
return Ok(());
}
for (pkgname, pkgpath, reason) in db.get_blockers(pkg.id)? {
let s = if path {
format!("{}{} ({})", if multi { " " } else { "" }, pkgpath, reason)
} else {
format!("{}{} ({})", if multi { " " } else { "" }, pkgname, reason)
};
if !try_println(&s) {
return Ok(());
}
}
}
}
ListCmd::BlockedBy { package, path } => {
let matches = match_packages(db, &package)?;
let multi = matches.len() > 1;
for pkg in matches {
if multi && !try_println(&format!("{} ({}):", pkg.pkgname, pkg.pkg_location)) {
return Ok(());
}
for (pkgname, pkgpath) in db.get_blocked_by(pkg.id)? {
let s = if path {
format!("{}{}", if multi { " " } else { "" }, pkgpath)
} else {
format!("{}{}", if multi { " " } else { "" }, pkgname)
};
if !try_println(&s) {
return Ok(());
}
}
}
}
}
Ok(())
}
fn match_packages(db: &Database, pattern: &str) -> Result<Vec<bob::db::PackageRow>> {
let re = pkg_pattern(pattern)?;
let matches: Vec<bob::db::PackageRow> = db
.get_all_packages()?
.into_iter()
.filter(|p| re.is_match(&p.pkgname) || re.is_match(&p.pkg_location))
.collect();
if matches.is_empty() {
bail!("No packages match '{}'", pattern);
}
Ok(matches)
}
fn collect_transitive_deps<'a>(
pkg: &'a str,
deps: &'a HashMap<String, Vec<String>>,
result: &mut HashSet<&'a str>,
) {
if let Some(pkg_deps) = deps.get(pkg) {
for dep in pkg_deps {
if result.insert(dep.as_str()) {
collect_transitive_deps(dep.as_str(), deps, result);
}
}
}
}
fn print_build_tree(
db: &Database,
use_path: bool,
include_all: bool,
format: TreeOutput,
package: Option<&str>,
) -> Result<()> {
let buildable_pkgs = db.get_buildable_packages()?;
let pkgname_to_pkgpath: HashMap<String, String> = buildable_pkgs
.iter()
.map(|pkg| (pkg.pkgname.clone(), pkg.pkg_location.clone()))
.collect();
let pkgname_to_deps = db.get_all_resolved_deps()?;
let mut resolved: HashSet<String> = HashSet::new();
for (pkg, deps) in &pkgname_to_deps {
resolved.insert(pkg.clone());
resolved.extend(deps.iter().cloned());
}
let results = db.get_all_build_results()?;
let excluded: HashSet<String> = results
.iter()
.filter(|r| matches!(r.state, PackageState::UpToDate) || r.state.is_skip())
.map(|r| r.pkgname.pkgname().to_string())
.collect();
let up_to_date: HashSet<String> = results
.iter()
.filter(|r| matches!(r.state, PackageState::UpToDate))
.map(|r| r.pkgname.pkgname().to_string())
.collect();
let packages: HashSet<String> = if let Some(pattern) = package {
let re = pkg_pattern(pattern)?;
let matches: Vec<&str> = pkgname_to_pkgpath
.iter()
.filter(|(name, path)| {
resolved.contains(*name) && (re.is_match(name) || re.is_match(path))
})
.map(|(name, _)| name.as_str())
.collect();
if matches.is_empty() {
bail!("No packages match '{}'", pattern);
}
let mut required: HashSet<&str> = HashSet::new();
for pkg in &matches {
required.insert(pkg);
collect_transitive_deps(pkg, &pkgname_to_deps, &mut required);
}
let required: HashSet<String> = required.iter().map(|s| s.to_string()).collect();
if include_all {
required
} else {
required
.into_iter()
.filter(|p| !excluded.contains(p))
.collect()
}
} else {
let all_buildable: HashSet<String> = buildable_pkgs
.iter()
.filter(|pkg| resolved.contains(&pkg.pkgname))
.map(|pkg| pkg.pkgname.clone())
.collect();
if include_all {
all_buildable
} else {
all_buildable
.into_iter()
.filter(|p| !excluded.contains(p))
.collect()
}
};
let up_to_date_label: &str = PackageStateKind::UpToDate.into();
if packages.is_empty() {
println!("All packages are {up_to_date_label}");
return Ok(());
}
let mut filtered_deps: HashMap<String, Vec<String>> = pkgname_to_deps
.iter()
.filter(|(pkg, _)| packages.contains(*pkg))
.map(|(pkg, deps)| {
(
pkg.clone(),
deps.iter()
.filter(|d| packages.contains(*d))
.cloned()
.collect(),
)
})
.collect();
for pkg in &packages {
filtered_deps.entry(pkg.clone()).or_default();
}
let mut levels: HashMap<String, usize> = HashMap::new();
loop {
let before = levels.len();
for (pkg, deps) in &filtered_deps {
if !levels.contains_key(pkg) && deps.iter().all(|d| levels.contains_key(d)) {
let level = deps
.iter()
.filter_map(|d| levels.get(d))
.max()
.map_or(0, |m| m + 1);
levels.insert(pkg.clone(), level);
}
}
if levels.len() == before {
break;
}
}
let max_level = levels.values().max().copied().unwrap_or(0);
let mut by_level: Vec<Vec<String>> = vec![Vec::new(); max_level + 1];
for (pkg, &level) in &levels {
by_level[level].push(pkg.clone());
}
for level_pkgs in &mut by_level {
level_pkgs.sort();
}
let display_name = |pkg: &str| -> String {
if use_path {
pkgname_to_pkgpath
.get(pkg)
.cloned()
.unwrap_or_else(|| pkg.to_string())
} else {
pkg.to_string()
}
};
let up_to_date_suffix = format!(" ({up_to_date_label})");
let term_width = terminal::size().map(|(w, _)| w as usize).unwrap_or(80);
let suffix_len = if include_all {
up_to_date_suffix.len()
} else {
0
};
let mut indent_width = 1;
for try_indent in [3, 2, 1] {
let fits = by_level.iter().enumerate().all(|(level, pkgs)| {
level == 0
|| pkgs.iter().all(|pkg| {
level * try_indent + display_name(pkg).len() + suffix_len <= term_width
})
});
if fits {
indent_width = try_indent;
break;
}
}
#[cfg(target_os = "netbsd")]
let (mid_conn, last_conn, span_mid, span_last) = match (format, indent_width) {
(TreeOutput::Utf8, 3) => ("├─ ", "└─ ", "└──── ", "└──── "),
(TreeOutput::Utf8, 2) => ("├ ", "└ ", "└── ", "└── "),
(TreeOutput::Utf8, _) => ("├ ", "└ ", "└─ ", "└─ "),
(TreeOutput::Ascii, 3) => ("|- ", "`- ", "`--+- ", "`---- "),
(TreeOutput::Ascii, 2) => ("| ", "` ", "`-+ ", "`-- "),
(TreeOutput::Ascii, _) => ("| ", "` ", "`+ ", "`- "),
(TreeOutput::None, _) => ("", "", "", ""),
};
#[cfg(not(target_os = "netbsd"))]
let (mid_conn, last_conn, span_mid, span_last) = match (format, indent_width) {
(TreeOutput::Utf8, 3) => ("├─ ", "╰─ ", "╰──┬─ ", "╰──── "),
(TreeOutput::Utf8, 2) => ("├ ", "╰ ", "╰─┬ ", "╰── "),
(TreeOutput::Utf8, _) => ("├ ", "╰ ", "╰┬ ", "╰─ "),
(TreeOutput::Ascii, 3) => ("|- ", "`- ", "`--+- ", "`---- "),
(TreeOutput::Ascii, 2) => ("| ", "` ", "`-+ ", "`-- "),
(TreeOutput::Ascii, _) => ("| ", "` ", "`+ ", "`- "),
(TreeOutput::None, _) => ("", "", "", ""),
};
let max_level = by_level.len().saturating_sub(1);
let (dim, reset) = if use_color() && format != TreeOutput::None {
("\x1b[2m", "\x1b[0m")
} else {
("", "")
};
'outer: for (level, pkgs) in by_level.iter().enumerate() {
let pkg_count = pkgs.len();
let has_next_level = level < max_level;
for (i, pkg) in pkgs.iter().enumerate() {
let name = display_name(pkg);
let suffix = if include_all && up_to_date.contains(pkg) {
up_to_date_suffix.as_str()
} else {
""
};
let is_first = i == 0;
let is_last = i == pkg_count - 1;
let line = if level == 0 {
format!("{}{}", name, suffix)
} else if format == TreeOutput::None {
format!("{}{}{}", " ".repeat(indent_width * level), name, suffix)
} else if is_first && level > 1 {
let prefix = " ".repeat(indent_width * (level - 2));
let span = if pkg_count == 1 && !has_next_level {
span_last
} else {
span_mid
};
format!("{}{}{}{}{}{}", dim, prefix, span, reset, name, suffix)
} else {
let indent = " ".repeat(indent_width * (level - 1));
let conn = if is_last && !has_next_level {
last_conn
} else {
mid_conn
};
format!("{}{}{}{}{}{}", dim, indent, conn, reset, name, suffix)
};
if !try_println(&line) {
break 'outer;
}
}
}
Ok(())
}