use std::path::{Path, PathBuf};
use std::{env, fs};
use xshell::{Cmd, Shell};
const LOG_LEVEL_ENV_VAR: &str = "RBMT_LOG_LEVEL";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OutputMode {
Verbose,
Progress,
Quiet,
}
impl OutputMode {
pub fn from_env() -> Self {
match env::var(LOG_LEVEL_ENV_VAR).as_deref() {
Ok("progress") => Self::Progress,
Ok("quiet") => Self::Quiet,
_ => Self::Verbose,
}
}
}
pub trait CmdExt {
fn run_verbose(&mut self) -> Result<(), Box<dyn std::error::Error>>;
fn set_release(self, release: bool) -> Self;
}
impl CmdExt for Cmd<'_> {
fn run_verbose(&mut self) -> Result<(), Box<dyn std::error::Error>> {
self.set_ignore_stdout(false);
self.set_ignore_stderr(false);
self.set_ignore_status(true);
let output = self.output()?;
if matches!(OutputMode::from_env(), OutputMode::Verbose) || !output.status.success() {
eprint!("{}", String::from_utf8(output.stderr)?);
print!("{}", String::from_utf8(output.stdout)?);
}
if !output.status.success() {
return Err(format!("Command failed: {}", output.status).into());
}
Ok(())
}
fn set_release(self, release: bool) -> Self {
if release {
self.arg("--release")
} else {
self
}
}
}
pub struct ProgressGuard {
disabled: bool,
}
impl ProgressGuard {
pub fn new() -> Self { Self { disabled: false } }
pub fn disable(&mut self) {
self.disabled = true;
if OutputMode::from_env() == OutputMode::Progress {
eprintln!();
}
}
}
impl Default for ProgressGuard {
fn default() -> Self { Self::new() }
}
impl Drop for ProgressGuard {
fn drop(&mut self) {
if !self.disabled && OutputMode::from_env() == OutputMode::Progress {
eprintln!();
}
}
}
#[derive(Clone, Debug)]
pub struct Package {
pub name: String,
pub dir: PathBuf,
pub id: String,
}
#[macro_export]
macro_rules! rbmt_cmd {
($sh:expr, $($arg:tt)*) => {{
let mut cmd = xshell::cmd!($sh, $($arg)*);
match $crate::environment::OutputMode::from_env() {
$crate::environment::OutputMode::Verbose => {},
$crate::environment::OutputMode::Progress | $crate::environment::OutputMode::Quiet => {
cmd = cmd.quiet().ignore_stderr();
}
}
cmd
}};
}
pub const PROGRESS_SYMBOLS: &[&str] = &["b", "B", "$", "#"];
#[macro_export]
macro_rules! rbmt_eprintln {
($($arg:tt)*) => {{
match $crate::environment::OutputMode::from_env() {
$crate::environment::OutputMode::Verbose => {
eprintln!($($arg)*);
}
$crate::environment::OutputMode::Progress => {
let msg = format!($($arg)*);
let hash = msg
.as_bytes()
.iter()
.fold(0usize, |acc, &b| acc.wrapping_mul(31).wrapping_add(b as usize));
let symbol = $crate::environment::PROGRESS_SYMBOLS[hash % $crate::environment::PROGRESS_SYMBOLS.len()];
eprint!("\r[{}] {}\x1b[K", symbol, msg);
}
$crate::environment::OutputMode::Quiet => {}
}
}};
}
pub fn get_workspace_packages(
sh: &Shell,
package_filter: &[String],
) -> Result<Vec<Package>, Box<dyn std::error::Error>> {
let metadata = rbmt_cmd!(sh, "cargo metadata --no-deps --format-version 1").read()?;
let json: serde_json::Value = serde_json::from_str(&metadata)?;
let all_packages: Vec<Package> = json["packages"]
.as_array()
.ok_or("Missing 'packages' field in cargo metadata")?
.iter()
.filter_map(|package| {
Some(Package {
name: package["name"].as_str()?.to_string(),
dir: PathBuf::from(
package["manifest_path"].as_str()?.trim_end_matches("/Cargo.toml"),
),
id: package["id"].as_str()?.to_string(),
})
})
.collect();
if package_filter.is_empty() {
return Ok(all_packages);
}
let mut resolved_names: Vec<String> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for requested in package_filter {
if all_packages.iter().any(|pkg| &pkg.name == requested) {
resolved_names.push(requested.clone());
continue;
}
let dir_matches: Vec<&Package> = all_packages
.iter()
.filter(|pkg| {
pkg.dir.file_name().and_then(|n| n.to_str()).is_some_and(|n| n == requested)
})
.collect();
match dir_matches.len() {
0 => {
errors.push(format!("Package not found in workspace: '{}'", requested));
}
1 => {
resolved_names.push(dir_matches[0].name.clone());
}
_ => {
errors.push(format!(
"Ambiguous package '{}': use the manifest name to disambiguate.",
requested
));
}
}
}
if !errors.is_empty() {
let mut error_msg = errors.join("\n\n");
error_msg.push_str("\n\nAvailable packages (manifest name / directory):");
for pkg in &all_packages {
error_msg.push_str(&format!("\n - {} ({})", pkg.name, pkg.dir.display()));
}
return Err(error_msg.into());
}
let package_info: Vec<Package> = all_packages
.into_iter()
.filter(|pkg| resolved_names.iter().any(|r| r == &pkg.name))
.collect();
Ok(package_info)
}
pub fn get_workspace_root(sh: &Shell) -> Result<PathBuf, Box<dyn std::error::Error>> {
let metadata = rbmt_cmd!(sh, "cargo metadata --no-deps --format-version 1").read()?;
let json: serde_json::Value = serde_json::from_str(&metadata)?;
let root = json["workspace_root"].as_str().ok_or("Missing workspace_root in cargo metadata")?;
Ok(PathBuf::from(root))
}
pub fn get_target_dir(sh: &Shell) -> Result<String, Box<dyn std::error::Error>> {
let metadata = rbmt_cmd!(sh, "cargo metadata --no-deps --format-version 1").read()?;
let json: serde_json::Value = serde_json::from_str(&metadata)?;
let target_dir =
json["target_directory"].as_str().ok_or("Missing target_directory in cargo metadata")?;
Ok(target_dir.to_string())
}
pub fn discover_features(
sh: &Shell,
package: &Package,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let metadata = rbmt_cmd!(sh, "cargo metadata --format-version 1 --no-deps").read()?;
let json: serde_json::Value = serde_json::from_str(&metadata)?;
let packages =
json["packages"].as_array().ok_or("Missing 'packages' field in cargo metadata")?;
let manifest_path = package.dir.join("Cargo.toml");
let pkg = packages
.iter()
.find(|p| p["manifest_path"].as_str().is_some_and(|path| Path::new(path) == manifest_path))
.ok_or_else(|| format!("Package not found in cargo metadata: {}", package.dir.display()))?;
let mut features: Vec<String> = pkg["features"]
.as_object()
.map(|f| f.keys().filter(|k| *k != "default").cloned().collect())
.unwrap_or_default();
features.sort();
Ok(features)
}
pub fn git_commit_id(sh: &Shell) -> Option<String> {
sh.cmd("git").args(["rev-parse", "HEAD"]).quiet().read().ok().map(|s| s.trim().to_owned())
}
pub struct Manifest {
pub exclude: Vec<String>,
}
impl Manifest {
pub fn read(package_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
#[derive(serde::Deserialize)]
struct CargoToml {
package: CargoPackage,
}
#[derive(serde::Deserialize)]
struct CargoPackage {
#[serde(default)]
exclude: Vec<String>,
}
let contents = fs::read_to_string(package_dir.join("Cargo.toml"))?;
let cargo_toml: CargoToml = toml::from_str(&contents)?;
Ok(Self { exclude: cargo_toml.package.exclude })
}
}
#[derive(serde::Deserialize, Default)]
pub(crate) struct PackageManifest<T: Default> {
#[serde(default)]
pub(crate) package: PackageTable<T>,
}
#[derive(serde::Deserialize, Default)]
pub(crate) struct WorkspaceManifest<T: Default> {
#[serde(default)]
pub(crate) workspace: WorkspaceTable<T>,
#[serde(default)]
pub(crate) package: PackageTable<T>,
}
#[derive(serde::Deserialize, Default)]
pub(crate) struct WorkspaceTable<T: Default> {
#[serde(default)]
pub(crate) metadata: MetadataTable<T>,
}
#[derive(serde::Deserialize, Default)]
pub(crate) struct PackageTable<T: Default> {
#[serde(default)]
pub(crate) metadata: MetadataTable<T>,
}
#[derive(serde::Deserialize, Default)]
pub(crate) struct MetadataTable<T: Default> {
#[serde(default)]
pub(crate) rbmt: T,
}