use super::DepFilter;
use aube_lockfile::LockfileGraph;
use clap::Args;
use miette::{Context, IntoDiagnostic};
use serde::Serialize;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
pub const AFTER_LONG_HELP: &str = "\
Examples:
$ aube licenses
├─ Apache-2.0
│ └─ typescript@5.4.5
├─ ISC
│ └─ semver@7.6.0
└─ MIT
├─ express@4.19.2
├─ lodash@4.17.21
└─ zod@3.23.8
# Only production deps
$ aube licenses --prod
# Include each package's store path
$ aube licenses --long
# JSON array, one object per package
$ aube licenses --json
";
#[derive(Debug, Args)]
pub struct LicensesArgs {
#[arg(value_parser = ["ls"], hide = true)]
pub subcommand: Option<String>,
#[arg(short = 'D', long, conflicts_with = "prod")]
pub dev: bool,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub long: bool,
#[arg(
short = 'P',
long,
conflicts_with = "dev",
visible_alias = "production"
)]
pub prod: bool,
#[command(flatten)]
pub network: crate::cli_args::NetworkArgs,
}
#[derive(Debug, Serialize)]
struct Row {
name: String,
version: String,
license: String,
#[serde(skip_serializing_if = "Option::is_none")]
path: Option<String>,
}
pub async fn run(args: LicensesArgs) -> miette::Result<()> {
args.network.install_overrides();
let _ = args.subcommand;
let cwd = crate::dirs::project_root()?;
let manifest = super::load_manifest(&cwd.join("package.json"))?;
let graph = match aube_lockfile::parse_lockfile(&cwd, &manifest) {
Ok(g) => g,
Err(aube_lockfile::Error::NotFound(_)) => {
eprintln!(
"No lockfile found. Run `{}` first.",
aube_util::cmd("install")
);
return Ok(());
}
Err(e) => return Err(miette::Report::new(e)).wrap_err("failed to parse lockfile"),
};
let filter = DepFilter::from_flags(args.prod, args.dev);
let filtered = graph.filter_deps(|d| filter.keeps(d.dep_type));
let aube_dir = super::resolve_virtual_store_dir_for_cwd(&cwd);
let installed_layout = crate::state::read_state_layout(&cwd)
.or_else(|| crate::state::read_default_state_layout(&cwd));
let installed_hoisted = installed_layout
.as_ref()
.map(|layout| matches!(layout.linker, crate::state::InstallLayoutMode::Hoisted))
.unwrap_or_else(|| {
super::with_settings_ctx(&cwd, |ctx| {
matches!(
aube_settings::resolved::node_linker(ctx),
aube_settings::resolved::NodeLinker::Hoisted
)
})
});
let hoisted_placements = if installed_hoisted {
let modules_dir_name = installed_layout
.as_ref()
.map(|layout| layout.modules_dir_name.as_str())
.filter(|name| !name.is_empty())
.map(str::to_owned)
.or_else(|| {
installed_layout
.as_ref()
.and_then(|layout| infer_legacy_modules_dir_name(layout, &graph))
})
.unwrap_or_else(|| "node_modules".to_string());
let recorded_hoisting_limits = installed_layout
.as_ref()
.and_then(|layout| layout.hoisting_limits)
.map(|limits| match limits {
crate::state::InstallHoistingLimits::None => aube_linker::HoistingLimits::None,
crate::state::InstallHoistingLimits::Workspaces => {
aube_linker::HoistingLimits::Workspaces
}
crate::state::InstallHoistingLimits::Dependencies => {
aube_linker::HoistingLimits::Dependencies
}
});
Some(match recorded_hoisting_limits {
Some(limits) => {
aube_linker::HoistedPlacements::from_graph(&cwd, &graph, &modules_dir_name, limits)?
}
None => legacy_hoisted_placements(&cwd, &graph, &modules_dir_name)?,
})
} else {
None
};
let rows = collect_rows(&aube_dir, &filtered, hoisted_placements.as_ref(), args.long);
if args.json {
render_json(&rows)?;
} else {
render_grouped(&rows, args.long);
}
Ok(())
}
fn infer_legacy_modules_dir_name(
layout: &crate::state::InstallLayoutState,
graph: &LockfileGraph,
) -> Option<String> {
let entries = layout.direct_entries.get(".")?;
let deps = graph.importers.get(".")?;
entries.iter().zip(deps).find_map(|(entry, dep)| {
let mut modules_dir = PathBuf::from(entry);
for _ in Path::new(&dep.name).components() {
if !modules_dir.pop() {
return None;
}
}
Some(modules_dir.to_string_lossy().into_owned())
})
}
fn legacy_hoisted_placements(
cwd: &Path,
graph: &LockfileGraph,
modules_dir_name: &str,
) -> Result<aube_linker::HoistedPlacements, aube_linker::Error> {
let mut best = None;
for limits in [
aube_linker::HoistingLimits::None,
aube_linker::HoistingLimits::Workspaces,
aube_linker::HoistingLimits::Dependencies,
] {
let placements =
aube_linker::HoistedPlacements::from_graph(cwd, graph, modules_dir_name, limits)?;
let matches = graph
.packages
.keys()
.filter(|dep_path| placements.package_dir(dep_path).is_some())
.count();
if best
.as_ref()
.is_none_or(|(best_matches, _)| matches > *best_matches)
{
best = Some((matches, placements));
}
}
Ok(best.map_or_else(
aube_linker::HoistedPlacements::default,
|(_, placements)| placements,
))
}
fn collect_rows(
aube_dir: &Path,
graph: &LockfileGraph,
hoisted_placements: Option<&aube_linker::HoistedPlacements>,
long: bool,
) -> Vec<Row> {
let mut seen: BTreeSet<(String, String)> = BTreeSet::new();
let mut rows: Vec<Row> = Vec::new();
for pkg in graph.packages.values() {
if !seen.insert((pkg.name.clone(), pkg.version.clone())) {
continue;
}
let pkg_dir = hoisted_placements
.and_then(|placements| placements.package_dir(&pkg.dep_path))
.map(Path::to_path_buf)
.unwrap_or_else(|| virtual_store_pkg_dir(aube_dir, &pkg.dep_path, &pkg.name));
let license = read_license(&pkg_dir);
rows.push(Row {
name: pkg.name.clone(),
version: pkg.version.clone(),
license: license.unwrap_or_else(|| "UNKNOWN".to_string()),
path: if long {
Some(pkg_dir.display().to_string())
} else {
None
},
});
}
rows.sort_by(|a, b| {
a.license
.cmp(&b.license)
.then_with(|| a.name.cmp(&b.name))
.then_with(|| a.version.cmp(&b.version))
});
rows
}
fn virtual_store_pkg_dir(aube_dir: &Path, dep_path: &str, name: &str) -> PathBuf {
use aube_lockfile::dep_path_filename::{
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, dep_path_to_filename,
};
aube_dir
.join(dep_path_to_filename(
dep_path,
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
))
.join("node_modules")
.join(name)
}
fn read_license(pkg_dir: &Path) -> Option<String> {
let bytes = std::fs::read(pkg_dir.join("package.json")).ok()?;
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
value.get("license").and_then(extract_license).or_else(|| {
value
.get("licenses")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(extract_license)
})
}
fn extract_license(v: &serde_json::Value) -> Option<String> {
match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(obj) => {
obj.get("type").and_then(|t| t.as_str()).map(String::from)
}
_ => None,
}
}
fn render_grouped(rows: &[Row], long: bool) {
if rows.is_empty() {
println!("(no dependencies)");
return;
}
let mut by_license: BTreeMap<&str, Vec<&Row>> = BTreeMap::new();
for row in rows {
by_license
.entry(row.license.as_str())
.or_default()
.push(row);
}
let last_idx = by_license.len().saturating_sub(1);
for (i, (license, entries)) in by_license.iter().enumerate() {
let license_connector = if i == last_idx { "└─" } else { "├─" };
println!("{license_connector} {license}");
let inner_prefix = if i == last_idx { " " } else { "│ " };
let last_entry = entries.len().saturating_sub(1);
for (j, row) in entries.iter().enumerate() {
let entry_connector = if j == last_entry { "└─" } else { "├─" };
println!(
"{inner_prefix}{entry_connector} {}@{}",
row.name, row.version
);
if long && let Some(path) = &row.path {
let tail_prefix = if j == last_entry { " " } else { "│ " };
println!("{inner_prefix}{tail_prefix} {path}");
}
}
}
}
fn render_json(rows: &[Row]) -> miette::Result<()> {
let out = serde_json::to_string_pretty(rows)
.into_diagnostic()
.wrap_err("failed to serialize licenses output")?;
println!("{out}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_license_string() {
let v = serde_json::json!("MIT");
assert_eq!(extract_license(&v).as_deref(), Some("MIT"));
}
#[test]
fn extract_license_object() {
let v = serde_json::json!({ "type": "Apache-2.0", "url": "..." });
assert_eq!(extract_license(&v).as_deref(), Some("Apache-2.0"));
}
#[test]
fn extract_license_missing_type() {
let v = serde_json::json!({ "url": "..." });
assert!(extract_license(&v).is_none());
}
}