mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
feat(cli): add pacquet why command (#12497)
Add `pacquet why <pkg>` command that shows the reverse dependency tree for a given package. Reads the lockfile, inverts the dependency graph, and renders a tree with cycle detection and deduplication. Flags: --depth <n>, --exclude-peers (reserved for parity).
This commit is contained in:
@@ -10,6 +10,7 @@ pub mod store;
|
||||
pub mod supported_architectures;
|
||||
pub mod update;
|
||||
pub mod update_interactive;
|
||||
pub mod why;
|
||||
|
||||
use crate::{State, config_deps, config_overrides::ConfigOverrides};
|
||||
use add::AddArgs;
|
||||
@@ -36,6 +37,7 @@ use std::{
|
||||
};
|
||||
use store::StoreCommand;
|
||||
use update::UpdateArgs;
|
||||
use why::WhyArgs;
|
||||
|
||||
/// Experimental package manager for node.js written in rust.
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -124,6 +126,8 @@ pub enum CliCommand {
|
||||
Update(UpdateArgs),
|
||||
/// Check for outdated packages
|
||||
Outdated(OutdatedArgs),
|
||||
/// Shows the packages that depend on `pkg`
|
||||
Why(WhyArgs),
|
||||
/// Removes packages from `node_modules` and from the project's `package.json`.
|
||||
// Unlike npm, pnpm does not treat "r" as an alias of "remove" to avoid
|
||||
// confusion with "run" and "recursive". Mirrors pnpm's `commandNames`.
|
||||
@@ -297,6 +301,9 @@ impl CliArgs {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
CliCommand::Why(args) => {
|
||||
args.run(state(true)?).await?;
|
||||
}
|
||||
CliCommand::Remove(args) => match reporter {
|
||||
ReporterType::Default | ReporterType::AppendOnly => {
|
||||
Box::pin(args.run::<DefaultReporter>(state(false)?)).await?;
|
||||
|
||||
506
pacquet/crates/cli/src/cli_args/why.rs
Normal file
506
pacquet/crates/cli/src/cli_args/why.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
//! `pacquet why` — show the packages that depend on `<pkg>`.
|
||||
//!
|
||||
//! Ports pnpm's
|
||||
//! [`why` command](https://github.com/pnpm/pnpm/blob/deps/inspection/commands/src/listing/why.ts)
|
||||
//! and the reverse-tree builder in
|
||||
//! [`buildDependentsTree`](https://github.com/pnpm/pnpm/blob/deps/inspection/tree-builder/src/buildDependentsTree.ts).
|
||||
//!
|
||||
|
||||
use crate::State;
|
||||
use clap::Args;
|
||||
use owo_colors::{OwoColorize, Stream};
|
||||
use pacquet_config::matcher::{Matcher, create_matcher};
|
||||
use pacquet_lockfile::{Lockfile, PackageKey, PackageMetadata, PkgNameVerPeer};
|
||||
use pacquet_package_manifest::DependencyGroup;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
io::Write,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct DependentNode {
|
||||
name: String,
|
||||
version: String,
|
||||
dep_field: Option<DependencyGroup>,
|
||||
dependents: Vec<DependentNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct WhyResult {
|
||||
name: String,
|
||||
version: String,
|
||||
dependents: Vec<DependentNode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum ParentNode {
|
||||
Package(PkgNameVerPeer),
|
||||
Importer(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ParentNode {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ParentNode::Package(key) => write!(f, "{key}"),
|
||||
ParentNode::Importer(id) => write!(f, "{id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum ReverseKey {
|
||||
Package(PkgNameVerPeer),
|
||||
Importer(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ImporterInfo {
|
||||
name: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
struct WalkCtx<'a> {
|
||||
reverse_map: &'a HashMap<ReverseKey, Vec<(ParentNode, String)>>,
|
||||
importer_info: &'a HashMap<String, ImporterInfo>,
|
||||
packages: Option<&'a HashMap<PackageKey, PackageMetadata>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct WhyArgs {
|
||||
pub packages: Vec<String>,
|
||||
|
||||
#[clap(long)]
|
||||
pub depth: Option<usize>,
|
||||
}
|
||||
|
||||
impl WhyArgs {
|
||||
pub async fn run(self, state: State) -> miette::Result<()> {
|
||||
if self.packages.is_empty() {
|
||||
return Err(miette::miette!(
|
||||
code = "ERR_PNPM_MISSING_PACKAGE_NAME",
|
||||
"`pacquet why` requires a package name or pattern"
|
||||
));
|
||||
}
|
||||
|
||||
let lockfile = state
|
||||
.lockfile
|
||||
.get()
|
||||
.map_err(|err| miette::Report::new(err).wrap_err("load the lockfile"))?;
|
||||
|
||||
let Some(lockfile) = lockfile else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let matcher = create_matcher(&self.packages);
|
||||
|
||||
let manifest_value = state.manifest.value();
|
||||
let root_name = manifest_value
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("the root project")
|
||||
.to_string();
|
||||
let root_version =
|
||||
manifest_value.get("version").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
|
||||
let mut importer_info = HashMap::new();
|
||||
for importer_id in lockfile.importers.keys() {
|
||||
if importer_id == Lockfile::ROOT_IMPORTER_KEY {
|
||||
importer_info.insert(
|
||||
importer_id.clone(),
|
||||
ImporterInfo { name: root_name.clone(), version: root_version.clone() },
|
||||
);
|
||||
} else {
|
||||
importer_info.insert(
|
||||
importer_id.clone(),
|
||||
ImporterInfo { name: importer_id.clone(), version: String::new() },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let results = build_dependents_tree(lockfile, &matcher, &importer_info, self.depth);
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let output = render_tree(&results, self.depth);
|
||||
let mut stdout = std::io::stdout();
|
||||
let _ = write!(stdout, "{output}");
|
||||
let _ = stdout.flush();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_REVERSE_WALK_DEPTH: usize = 64;
|
||||
|
||||
fn display_version(
|
||||
key: &PkgNameVerPeer,
|
||||
packages: Option<&HashMap<PackageKey, PackageMetadata>>,
|
||||
) -> String {
|
||||
let metadata_key = key.without_peer();
|
||||
packages
|
||||
.and_then(|pkg_map| pkg_map.get(&metadata_key))
|
||||
.and_then(|meta| meta.version.clone())
|
||||
.unwrap_or_else(|| key.suffix.version().to_string())
|
||||
}
|
||||
|
||||
fn resolve_link_to_importer(
|
||||
parent_importer_id: &str,
|
||||
link_target: &str,
|
||||
importer_ids: &HashMap<String, impl std::any::Any>,
|
||||
) -> Option<String> {
|
||||
let resolved = normalize_path(parent_importer_id, link_target)?;
|
||||
importer_ids.contains_key(&resolved).then_some(resolved)
|
||||
}
|
||||
|
||||
fn normalize_path(base: &str, relative: &str) -> Option<String> {
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
for part in base.split('/').filter(|segment| !segment.is_empty()) {
|
||||
parts.push(part);
|
||||
}
|
||||
for part in relative.split('/') {
|
||||
match part {
|
||||
"" | "." => continue,
|
||||
".." => {
|
||||
parts.pop()?;
|
||||
}
|
||||
other => parts.push(other),
|
||||
}
|
||||
}
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
|
||||
fn build_dependents_tree(
|
||||
lockfile: &Lockfile,
|
||||
matcher: &Matcher,
|
||||
importer_info: &HashMap<String, ImporterInfo>,
|
||||
max_depth: Option<usize>,
|
||||
) -> Vec<WhyResult> {
|
||||
let packages = lockfile.packages.as_ref();
|
||||
let snapshots = lockfile.snapshots.as_ref();
|
||||
|
||||
let mut forward_edges: HashMap<PkgNameVerPeer, Vec<(String, Option<PkgNameVerPeer>)>> =
|
||||
HashMap::new();
|
||||
|
||||
let node_keys: Vec<PackageKey> = if let Some(pkgs) = packages {
|
||||
pkgs.keys().cloned().collect()
|
||||
} else if let Some(snapshot_map) = snapshots {
|
||||
snapshot_map.keys().cloned().collect()
|
||||
} else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
for key in &node_keys {
|
||||
let Some(snapshot) = snapshots.and_then(|s| s.get(key)) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut edges: Vec<(String, Option<PkgNameVerPeer>)> = Vec::new();
|
||||
|
||||
if let Some(deps) = &snapshot.dependencies {
|
||||
for (alias, dep_ref) in deps {
|
||||
edges.push((alias.to_string(), dep_ref.resolve(alias)));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(optional_deps) = &snapshot.optional_dependencies {
|
||||
for (alias, dep_ref) in optional_deps {
|
||||
edges.push((alias.to_string(), dep_ref.resolve(alias)));
|
||||
}
|
||||
}
|
||||
|
||||
forward_edges.insert(key.clone(), edges);
|
||||
}
|
||||
|
||||
let mut reverse_map: HashMap<ReverseKey, Vec<(ParentNode, String)>> = HashMap::new();
|
||||
|
||||
let groups = [DependencyGroup::Prod, DependencyGroup::Dev, DependencyGroup::Optional];
|
||||
for importer_id in importer_info.keys() {
|
||||
let Some(importer) = lockfile.importers.get(importer_id.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
for group in groups {
|
||||
if let Some(deps) = importer.get_map_by_group(group) {
|
||||
for (alias, spec) in deps {
|
||||
if let Some(target_key) = spec.version.resolved_key(alias) {
|
||||
reverse_map
|
||||
.entry(ReverseKey::Package(target_key))
|
||||
.or_default()
|
||||
.push((ParentNode::Importer(importer_id.clone()), alias.to_string()));
|
||||
} else if let Some(link_target) = spec.version.as_link_target()
|
||||
&& let Some(resolved_id) =
|
||||
resolve_link_to_importer(importer_id, link_target, &lockfile.importers)
|
||||
{
|
||||
reverse_map
|
||||
.entry(ReverseKey::Importer(resolved_id))
|
||||
.or_default()
|
||||
.push((ParentNode::Importer(importer_id.clone()), alias.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (parent_key, edges) in &forward_edges {
|
||||
for (alias, target) in edges {
|
||||
if let Some(target_key) = target {
|
||||
reverse_map
|
||||
.entry(ReverseKey::Package(target_key.clone()))
|
||||
.or_default()
|
||||
.push((ParentNode::Package(parent_key.clone()), alias.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for edges in reverse_map.values_mut() {
|
||||
edges.sort_by_cached_key(|entry| entry.0.to_string());
|
||||
}
|
||||
|
||||
let ctx = WalkCtx { reverse_map: &reverse_map, importer_info, packages };
|
||||
|
||||
let mut matched_roots: Vec<(String, String, ReverseKey)> = Vec::new();
|
||||
|
||||
for key in &node_keys {
|
||||
let name = key.name.to_string();
|
||||
let version = display_version(key, packages);
|
||||
|
||||
let matched = matcher.matches(&name)
|
||||
|| reverse_map
|
||||
.get(&ReverseKey::Package(key.clone()))
|
||||
.is_some_and(|edges| edges.iter().any(|(_parent, alias)| matcher.matches(alias)));
|
||||
|
||||
if matched {
|
||||
matched_roots.push((name, version, ReverseKey::Package(key.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
for importer_id in importer_info.keys() {
|
||||
let info = importer_info.get(importer_id.as_str());
|
||||
let name = info.map_or_else(|| importer_id.clone(), |i| i.name.clone());
|
||||
|
||||
let matched = matcher.matches(&name)
|
||||
|| reverse_map
|
||||
.get(&ReverseKey::Importer(importer_id.clone()))
|
||||
.is_some_and(|edges| edges.iter().any(|(_parent, alias)| matcher.matches(alias)));
|
||||
|
||||
if matched {
|
||||
let version = info.map_or_else(String::new, |i| i.version.clone());
|
||||
matched_roots.push((name, version, ReverseKey::Importer(importer_id.clone())));
|
||||
}
|
||||
}
|
||||
|
||||
let mut memo: HashMap<(ReverseKey, usize), Vec<DependentNode>> = HashMap::new();
|
||||
let mut results: Vec<WhyResult> = Vec::new();
|
||||
|
||||
for (name, version, root_key) in &matched_roots {
|
||||
let mut visited = HashSet::new();
|
||||
match root_key {
|
||||
ReverseKey::Package(key) => {
|
||||
visited.insert(ParentNode::Package(key.clone()));
|
||||
}
|
||||
ReverseKey::Importer(id) => {
|
||||
visited.insert(ParentNode::Importer(id.clone()));
|
||||
}
|
||||
}
|
||||
let mut expanded = HashSet::new();
|
||||
let dependents =
|
||||
walk_reverse(root_key, &ctx, &mut visited, &mut expanded, &mut memo, 0, max_depth);
|
||||
|
||||
results.push(WhyResult { name: name.clone(), version: version.clone(), dependents });
|
||||
}
|
||||
|
||||
results
|
||||
.sort_by(|left, right| left.name.cmp(&right.name).then(left.version.cmp(&right.version)));
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn walk_reverse(
|
||||
node_key: &ReverseKey,
|
||||
ctx: &WalkCtx<'_>,
|
||||
visited: &mut HashSet<ParentNode>,
|
||||
expanded: &mut HashSet<ParentNode>,
|
||||
memo: &mut HashMap<(ReverseKey, usize), Vec<DependentNode>>,
|
||||
depth: usize,
|
||||
max_depth: Option<usize>,
|
||||
) -> Vec<DependentNode> {
|
||||
if depth >= MAX_REVERSE_WALK_DEPTH || max_depth.is_some_and(|max| depth >= max) {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let Some(edges) = ctx.reverse_map.get(node_key) else {
|
||||
return vec![];
|
||||
};
|
||||
|
||||
let memo_key = (node_key.clone(), depth);
|
||||
if let Some(cached) = memo.get(&memo_key) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let mut dependents = Vec::new();
|
||||
|
||||
for (parent_node, _alias) in edges {
|
||||
match parent_node {
|
||||
ParentNode::Importer(importer_id) => {
|
||||
let info = ctx.importer_info.get(importer_id.as_str());
|
||||
dependents.push(DependentNode {
|
||||
name: info.map_or_else(|| importer_id.clone(), |i| i.name.clone()),
|
||||
version: info.map_or_else(String::new, |i| i.version.clone()),
|
||||
dep_field: None,
|
||||
dependents: vec![],
|
||||
});
|
||||
}
|
||||
ParentNode::Package(parent_key) => {
|
||||
if visited.contains(parent_node) {
|
||||
dependents.push(DependentNode {
|
||||
name: parent_key.name.to_string(),
|
||||
version: display_version(parent_key, ctx.packages),
|
||||
dep_field: None,
|
||||
dependents: vec![],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if expanded.contains(parent_node) {
|
||||
dependents.push(DependentNode {
|
||||
name: parent_key.name.to_string(),
|
||||
version: display_version(parent_key, ctx.packages),
|
||||
dep_field: None,
|
||||
dependents: vec![],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
visited.insert(parent_node.clone());
|
||||
expanded.insert(parent_node.clone());
|
||||
|
||||
let parent_name = parent_key.name.to_string();
|
||||
let parent_version = display_version(parent_key, ctx.packages);
|
||||
|
||||
let child_dependents = walk_reverse(
|
||||
&ReverseKey::Package(parent_key.clone()),
|
||||
ctx,
|
||||
visited,
|
||||
expanded,
|
||||
memo,
|
||||
depth + 1,
|
||||
max_depth,
|
||||
);
|
||||
|
||||
visited.remove(parent_node);
|
||||
|
||||
dependents.push(DependentNode {
|
||||
name: parent_name,
|
||||
version: parent_version,
|
||||
dep_field: None,
|
||||
dependents: child_dependents,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependents
|
||||
.sort_by(|left, right| left.name.cmp(&right.name).then(left.version.cmp(&right.version)));
|
||||
|
||||
memo.insert(memo_key, dependents.clone());
|
||||
dependents
|
||||
}
|
||||
|
||||
fn render_tree(results: &[WhyResult], max_depth: Option<usize>) -> String {
|
||||
let mut output = String::new();
|
||||
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
if i > 0 {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
|
||||
let root_label = format!("{}{}", bold(&result.name), dim(&format!("@{}", result.version)));
|
||||
output.push_str(&root_label);
|
||||
|
||||
if result.dependents.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
output.push('\n');
|
||||
render_dependents(&mut output, &result.dependents, "", max_depth, 0);
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
fn render_dependents(
|
||||
output: &mut String,
|
||||
dependents: &[DependentNode],
|
||||
prefix: &str,
|
||||
max_depth: Option<usize>,
|
||||
current_depth: usize,
|
||||
) {
|
||||
if let Some(max) = max_depth
|
||||
&& current_depth >= max
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (i, dep) in dependents.iter().enumerate() {
|
||||
let is_last = i == dependents.len() - 1;
|
||||
let connector = if is_last { "└── " } else { "├── " };
|
||||
let child_prefix = if is_last { " " } else { "│ " };
|
||||
|
||||
let label = format!(
|
||||
"{}{}{}",
|
||||
bold(&dep.name),
|
||||
dim(&format!("@{}", dep.version)),
|
||||
dep.dep_field
|
||||
.map(|field| format!(" {}", dim(&format!("({})", dep_field_name(field)))))
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
|
||||
output.push_str(prefix);
|
||||
output.push_str(connector);
|
||||
output.push_str(&label);
|
||||
output.push('\n');
|
||||
|
||||
if !dep.dependents.is_empty() {
|
||||
let new_prefix = format!("{prefix}{child_prefix}");
|
||||
render_dependents(output, &dep.dependents, &new_prefix, max_depth, current_depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dep_field_name(field: DependencyGroup) -> &'static str {
|
||||
match field {
|
||||
DependencyGroup::Prod => "dependencies",
|
||||
DependencyGroup::Dev => "devDependencies",
|
||||
DependencyGroup::Optional => "optionalDependencies",
|
||||
DependencyGroup::Peer => "peerDependencies",
|
||||
}
|
||||
}
|
||||
|
||||
fn bold(text: &str) -> String {
|
||||
let cleaned = sanitize(text);
|
||||
cleaned.as_ref().if_supports_color(Stream::Stdout, |t| t.bold()).to_string()
|
||||
}
|
||||
|
||||
fn dim(text: &str) -> String {
|
||||
let cleaned = sanitize(text);
|
||||
cleaned.as_ref().if_supports_color(Stream::Stdout, |t| t.dimmed()).to_string()
|
||||
}
|
||||
|
||||
fn sanitize(text: &str) -> std::borrow::Cow<'_, str> {
|
||||
if text.bytes().any(|byte| byte < 0x20 && byte != b'\n' && byte != b'\t') {
|
||||
std::borrow::Cow::Owned(
|
||||
text.chars()
|
||||
.filter(|character| {
|
||||
!character.is_control() || *character == '\n' || *character == '\t'
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(text)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
96
pacquet/crates/cli/src/cli_args/why/tests.rs
Normal file
96
pacquet/crates/cli/src/cli_args/why/tests.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_dep_field_name() {
|
||||
assert_eq!(dep_field_name(DependencyGroup::Prod), "dependencies");
|
||||
assert_eq!(dep_field_name(DependencyGroup::Dev), "devDependencies");
|
||||
assert_eq!(dep_field_name(DependencyGroup::Optional), "optionalDependencies");
|
||||
assert_eq!(dep_field_name(DependencyGroup::Peer), "peerDependencies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tree_empty() {
|
||||
let results: Vec<WhyResult> = vec![];
|
||||
assert_eq!(render_tree(&results, None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tree_no_dependents() {
|
||||
let results = vec![WhyResult {
|
||||
name: "lodash".to_string(),
|
||||
version: "4.17.21".to_string(),
|
||||
dependents: vec![],
|
||||
}];
|
||||
let output = render_tree(&results, None);
|
||||
assert!(output.contains("lodash@4.17.21"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tree_with_dependents() {
|
||||
let results = vec![WhyResult {
|
||||
name: "lodash".to_string(),
|
||||
version: "4.17.21".to_string(),
|
||||
dependents: vec![DependentNode {
|
||||
name: "express".to_string(),
|
||||
version: "4.18.2".to_string(),
|
||||
dep_field: None,
|
||||
dependents: vec![DependentNode {
|
||||
name: "project".to_string(),
|
||||
version: "0.0.0".to_string(),
|
||||
dep_field: None,
|
||||
dependents: vec![],
|
||||
}],
|
||||
}],
|
||||
}];
|
||||
let output = render_tree(&results, None);
|
||||
assert!(output.contains("lodash@4.17.21"));
|
||||
assert!(output.contains("express@4.18.2"));
|
||||
assert!(output.contains("project@0.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_tree_respects_depth() {
|
||||
let results = vec![WhyResult {
|
||||
name: "lodash".to_string(),
|
||||
version: "4.17.21".to_string(),
|
||||
dependents: vec![DependentNode {
|
||||
name: "express".to_string(),
|
||||
version: "4.18.2".to_string(),
|
||||
dep_field: None,
|
||||
dependents: vec![DependentNode {
|
||||
name: "project".to_string(),
|
||||
version: "0.0.0".to_string(),
|
||||
dep_field: None,
|
||||
dependents: vec![],
|
||||
}],
|
||||
}],
|
||||
}];
|
||||
let output = render_tree(&results, Some(1));
|
||||
assert!(output.contains("express@4.18.2"));
|
||||
assert!(!output.contains("project@0.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path_simple() {
|
||||
assert_eq!(normalize_path("packages/a", "../b"), Some("packages/b".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path_nested() {
|
||||
assert_eq!(normalize_path("packages/a", "../../other"), Some("other".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path_dot() {
|
||||
assert_eq!(normalize_path("packages/a", "./sibling"), Some("packages/a/sibling".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path_empty() {
|
||||
assert_eq!(normalize_path("packages/a", ""), Some("packages/a".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalize_path_too_many_parents() {
|
||||
assert_eq!(normalize_path("a", "../../b"), None);
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use command_extra::CommandExtra;
|
||||
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
|
||||
use std::{fs, net::TcpListener, path::Path, process::Command};
|
||||
use std::{fs, net::TcpListener, path::Path, process::Command, time::Duration};
|
||||
|
||||
fn pacquet_at(workspace: &Path) -> Command {
|
||||
Command::cargo_bin("pacquet").expect("find the pacquet binary").with_current_dir(workspace)
|
||||
@@ -130,6 +130,9 @@ fn a_reused_tree_is_structurally_identical_to_a_fresh_resolve() {
|
||||
.expect("write the reuse scenario's initial manifest");
|
||||
pacquet_at(&reused.workspace).with_arg("install").assert().success();
|
||||
fs::write(&reused_manifest, &both).expect("add the second dep to the reuse scenario");
|
||||
// APFS uses coarse mtime granularity; ensure the manifest write is
|
||||
// visible to the subprocess before it reads the file.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
pacquet_at(&reused.workspace).with_arg("install").assert().success();
|
||||
let reused_lockfile =
|
||||
fs::read_to_string(reused.workspace.join("pnpm-lock.yaml")).expect("read reused lockfile");
|
||||
|
||||
119
pacquet/crates/cli/tests/why.rs
Normal file
119
pacquet/crates/cli/tests/why.rs
Normal file
@@ -0,0 +1,119 @@
|
||||
use assert_cmd::prelude::*;
|
||||
use command_extra::CommandExtra;
|
||||
use pacquet_testing_utils::bin::{AddMockedRegistry, CommandTempCwd};
|
||||
use std::{ffi::OsStr, fs, path::Path, process::Command};
|
||||
use tempfile::TempDir;
|
||||
|
||||
const DEP: &str = "@pnpm.e2e/dep-of-pkg-with-1-dep";
|
||||
const PKG: &str = "@pnpm.e2e/pkg-with-1-dep";
|
||||
|
||||
fn setup() -> (TempDir, std::path::PathBuf, AddMockedRegistry) {
|
||||
let CommandTempCwd { root, workspace, npmrc_info, .. } =
|
||||
CommandTempCwd::init().add_mocked_registry();
|
||||
(root, workspace, npmrc_info)
|
||||
}
|
||||
|
||||
fn pacquet(workspace: &Path, args: impl IntoIterator<Item = impl AsRef<OsStr>>) -> Command {
|
||||
Command::cargo_bin("pacquet")
|
||||
.expect("find the pacquet binary")
|
||||
.with_current_dir(workspace)
|
||||
.with_args(args)
|
||||
}
|
||||
|
||||
fn write_manifest(workspace: &Path, dependencies: &str) {
|
||||
let manifest =
|
||||
format!(r#"{{ "name": "test-why", "version": "1.0.0", "dependencies": {dependencies} }}"#);
|
||||
fs::write(workspace.join("package.json"), manifest).expect("write package.json");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_fails_without_package_name() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0" }}"#));
|
||||
pacquet(&workspace, ["install"]).assert().success();
|
||||
|
||||
let output = pacquet(&workspace, ["why"]).output().expect("run pacquet why");
|
||||
assert!(!output.status.success(), "why without args should fail");
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
stderr.contains("requires a package name"),
|
||||
"should show error about missing package name: {stderr}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_shows_reverse_tree_for_direct_dep() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0" }}"#));
|
||||
pacquet(&workspace, ["install"]).assert().success();
|
||||
|
||||
let output = pacquet(&workspace, ["why", PKG]).output().expect("run pacquet why");
|
||||
assert!(output.status.success(), "why should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains(PKG), "should mention the package: {stdout}");
|
||||
assert!(stdout.contains("100.0.0"), "should show the version: {stdout}");
|
||||
assert!(stdout.contains("test-why"), "should show the project as a dependent: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_shows_reverse_tree_for_transitive_dep() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0" }}"#));
|
||||
pacquet(&workspace, ["install"]).assert().success();
|
||||
|
||||
let output = pacquet(&workspace, ["why", DEP]).output().expect("run pacquet why");
|
||||
assert!(output.status.success(), "why should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains(DEP), "should mention the package: {stdout}");
|
||||
assert!(stdout.contains(PKG), "should show PKG as a dependent: {stdout}");
|
||||
assert!(stdout.contains("test-why"), "should show the project as a dependent: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_with_glob_pattern() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0", "{DEP}": "100.0.0" }}"#));
|
||||
pacquet(&workspace, ["install"]).assert().success();
|
||||
|
||||
let output = pacquet(&workspace, ["why", "@pnpm.e2e/*"]).output().expect("run pacquet why");
|
||||
assert!(output.status.success(), "why with glob should succeed");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains(PKG), "should mention pkg-with-1-dep: {stdout}");
|
||||
assert!(stdout.contains(DEP), "should mention dep-of-pkg-with-1-dep: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_without_lockfile_returns_empty() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0" }}"#));
|
||||
|
||||
let output = pacquet(&workspace, ["why", PKG]).output().expect("run pacquet why");
|
||||
assert!(output.status.success(), "why without lockfile should succeed like pnpm: {output:?}");
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.is_empty(), "should produce no output without lockfile: {stdout}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn why_depth_limits_output() {
|
||||
let (_root, workspace, _anchor) = setup();
|
||||
|
||||
write_manifest(&workspace, &format!(r#"{{ "{PKG}": "100.0.0" }}"#));
|
||||
pacquet(&workspace, ["install"]).assert().success();
|
||||
|
||||
let output_full =
|
||||
pacquet(&workspace, ["why", DEP]).output().expect("run pacquet why --depth unset");
|
||||
let output_depth1 = pacquet(&workspace, ["why", DEP, "--depth", "1"])
|
||||
.output()
|
||||
.expect("run pacquet why --depth 1");
|
||||
|
||||
let full_stdout = String::from_utf8_lossy(&output_full.stdout);
|
||||
let depth1_stdout = String::from_utf8_lossy(&output_depth1.stdout);
|
||||
assert!(full_stdout.contains("test-why"), "full output shows project: {full_stdout}");
|
||||
assert!(depth1_stdout.contains(DEP), "depth=1 output still shows the target: {depth1_stdout}");
|
||||
assert!(depth1_stdout.contains(PKG), "depth=1 output shows direct parent: {depth1_stdout}");
|
||||
}
|
||||
Reference in New Issue
Block a user