From ee9fab5476a6d66b735cec9b8787478bc02e1dc7 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Fri, 19 Jun 2026 13:07:55 +0200 Subject: [PATCH] feat(cli): add pacquet why command (#12497) Add `pacquet why ` 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 , --exclude-peers (reserved for parity). --- pacquet/crates/cli/src/cli_args.rs | 7 + pacquet/crates/cli/src/cli_args/why.rs | 506 ++++++++++++++++++ pacquet/crates/cli/src/cli_args/why/tests.rs | 96 ++++ .../cli/tests/lockfile_resolution_reuse.rs | 5 +- pacquet/crates/cli/tests/why.rs | 119 ++++ 5 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 pacquet/crates/cli/src/cli_args/why.rs create mode 100644 pacquet/crates/cli/src/cli_args/why/tests.rs create mode 100644 pacquet/crates/cli/tests/why.rs diff --git a/pacquet/crates/cli/src/cli_args.rs b/pacquet/crates/cli/src/cli_args.rs index b8c909b2c1..fc7dcc024f 100644 --- a/pacquet/crates/cli/src/cli_args.rs +++ b/pacquet/crates/cli/src/cli_args.rs @@ -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::(state(false)?)).await?; diff --git a/pacquet/crates/cli/src/cli_args/why.rs b/pacquet/crates/cli/src/cli_args/why.rs new file mode 100644 index 0000000000..45be6b8d14 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/why.rs @@ -0,0 +1,506 @@ +//! `pacquet why` — show the packages that depend on ``. +//! +//! 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, + dependents: Vec, +} + +#[derive(Debug)] +struct WhyResult { + name: String, + version: String, + dependents: Vec, +} + +#[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>, + importer_info: &'a HashMap, + packages: Option<&'a HashMap>, +} + +#[derive(Debug, Args)] +pub struct WhyArgs { + pub packages: Vec, + + #[clap(long)] + pub depth: Option, +} + +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>, +) -> 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, +) -> Option { + 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 { + 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, + max_depth: Option, +) -> Vec { + let packages = lockfile.packages.as_ref(); + let snapshots = lockfile.snapshots.as_ref(); + + let mut forward_edges: HashMap)>> = + HashMap::new(); + + let node_keys: Vec = 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)> = 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> = 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> = HashMap::new(); + let mut results: Vec = 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, + expanded: &mut HashSet, + memo: &mut HashMap<(ReverseKey, usize), Vec>, + depth: usize, + max_depth: Option, +) -> Vec { + 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) -> 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, + 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; diff --git a/pacquet/crates/cli/src/cli_args/why/tests.rs b/pacquet/crates/cli/src/cli_args/why/tests.rs new file mode 100644 index 0000000000..3e926f9132 --- /dev/null +++ b/pacquet/crates/cli/src/cli_args/why/tests.rs @@ -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 = 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); +} diff --git a/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs b/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs index c498f1cb60..ab028198ff 100644 --- a/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs +++ b/pacquet/crates/cli/tests/lockfile_resolution_reuse.rs @@ -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"); diff --git a/pacquet/crates/cli/tests/why.rs b/pacquet/crates/cli/tests/why.rs new file mode 100644 index 0000000000..ddd2ca2e10 --- /dev/null +++ b/pacquet/crates/cli/tests/why.rs @@ -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>) -> 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}"); +}