Skip to content

Commit 452e374

Browse files
authored
feat(pm): add why command for dependency analysis (#272)
### TL;DR Added a new `vite why` command (with `explain` alias) that shows why a package is installed in your project by automatically adapting to the detected package manager (pnpm/npm/yarn). ### What changed? - Added a new `why` command to the CLI that shows dependency relationships for specified packages - Implemented package manager-specific command resolution for pnpm, npm, and yarn - Added support for various output formats (JSON, long, parseable) - Implemented workspace filtering and dependency type filtering options - Added support for multiple packages (with graceful degradation for npm/yarn) - Created appropriate warning messages for unsupported features in specific package managers - Added comprehensive unit tests for command resolution ### How to test? ```bash # Basic usage vite why react # With JSON output vite why react --json # Multiple packages (works with pnpm) vite why react react-dom # Using the alias vite explain lodash # With workspace filtering vite why react --filter app # Recursive across workspaces vite why react -r # Check globally installed packages vite why typescript -g ``` ### Why make this change? This command helps developers understand dependency relationships, audit package usage, and debug dependency tree issues. Previously, users had to remember package manager-specific commands (`pnpm why`, `npm explain`, `yarn why`), but now they can use a single unified interface that automatically adapts to the detected package manager. This is especially useful in monorepos or when switching between projects with different package managers.
1 parent 363aa90 commit 452e374

File tree

31 files changed

+2833
-2
lines changed

31 files changed

+2833
-2
lines changed

crates/vite_install/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ mod install;
44
pub mod outdated;
55
pub mod remove;
66
pub mod update;
7+
pub mod why;
Lines changed: 380 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,380 @@
1+
use std::{collections::HashMap, process::ExitStatus};
2+
3+
use vite_error::Error;
4+
use vite_path::AbsolutePath;
5+
6+
use crate::package_manager::{
7+
PackageManager, PackageManagerType, ResolveCommandResult, format_path_env, run_command,
8+
};
9+
10+
/// Options for the why command.
11+
#[derive(Debug, Default)]
12+
pub struct WhyCommandOptions<'a> {
13+
pub packages: &'a [String],
14+
pub json: bool,
15+
pub long: bool,
16+
pub parseable: bool,
17+
pub recursive: bool,
18+
pub filters: Option<&'a [String]>,
19+
pub workspace_root: bool,
20+
pub prod: bool,
21+
pub dev: bool,
22+
pub depth: Option<u32>,
23+
pub no_optional: bool,
24+
pub global: bool,
25+
pub exclude_peers: bool,
26+
pub find_by: Option<&'a str>,
27+
pub pass_through_args: Option<&'a [String]>,
28+
}
29+
30+
impl PackageManager {
31+
/// Run the why command with the package manager.
32+
/// Return the exit status of the command.
33+
#[must_use]
34+
pub async fn run_why_command(
35+
&self,
36+
options: &WhyCommandOptions<'_>,
37+
cwd: impl AsRef<AbsolutePath>,
38+
) -> Result<ExitStatus, Error> {
39+
let resolve_command = self.resolve_why_command(options);
40+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
41+
.await
42+
}
43+
44+
/// Resolve the why command.
45+
#[must_use]
46+
pub fn resolve_why_command(&self, options: &WhyCommandOptions) -> ResolveCommandResult {
47+
let bin_name: String;
48+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
49+
let mut args: Vec<String> = Vec::new();
50+
51+
match self.client {
52+
PackageManagerType::Pnpm => {
53+
bin_name = "pnpm".into();
54+
55+
// pnpm: --filter must come before command
56+
if let Some(filters) = options.filters {
57+
for filter in filters {
58+
args.push("--filter".into());
59+
args.push(filter.clone());
60+
}
61+
}
62+
63+
args.push("why".into());
64+
65+
if options.json {
66+
args.push("--json".into());
67+
}
68+
69+
if options.long {
70+
args.push("--long".into());
71+
}
72+
73+
if options.parseable {
74+
args.push("--parseable".into());
75+
}
76+
77+
if options.recursive {
78+
args.push("--recursive".into());
79+
}
80+
81+
if options.workspace_root {
82+
args.push("--workspace-root".into());
83+
}
84+
85+
if options.prod {
86+
args.push("--prod".into());
87+
}
88+
89+
if options.dev {
90+
args.push("--dev".into());
91+
}
92+
93+
if let Some(depth) = options.depth {
94+
args.push("--depth".into());
95+
args.push(depth.to_string());
96+
}
97+
98+
if options.no_optional {
99+
args.push("--no-optional".into());
100+
}
101+
102+
if options.global {
103+
args.push("--global".into());
104+
}
105+
106+
if options.exclude_peers {
107+
args.push("--exclude-peers".into());
108+
}
109+
110+
if let Some(find_by) = options.find_by {
111+
args.push("--find-by".into());
112+
args.push(find_by.to_string());
113+
}
114+
115+
// Add packages (pnpm supports multiple packages)
116+
args.extend_from_slice(options.packages);
117+
}
118+
PackageManagerType::Yarn => {
119+
bin_name = "yarn".into();
120+
121+
args.push("why".into());
122+
123+
// yarn only supports single package
124+
if options.packages.len() > 1 {
125+
println!(
126+
"Warning: yarn only supports checking one package at a time, using first package"
127+
);
128+
}
129+
args.push(options.packages[0].clone());
130+
131+
// yarn@2+ supports --recursive
132+
if options.recursive && !self.version.starts_with("1.") {
133+
args.push("--recursive".into());
134+
}
135+
136+
// yarn@2+: Add --peers by default unless --exclude-peers is set
137+
if !self.version.starts_with("1.") && !options.exclude_peers {
138+
args.push("--peers".into());
139+
}
140+
141+
// Warn about unsupported flags
142+
if options.json {
143+
println!("Warning: --json not supported by yarn");
144+
}
145+
if options.long {
146+
println!("Warning: --long not supported by yarn");
147+
}
148+
if options.parseable {
149+
println!("Warning: --parseable not supported by yarn");
150+
}
151+
if let Some(filters) = options.filters {
152+
if !filters.is_empty() {
153+
println!("Warning: --filter not supported by yarn");
154+
}
155+
}
156+
if options.prod || options.dev {
157+
println!("Warning: --prod/--dev not supported by yarn");
158+
}
159+
if options.find_by.is_some() {
160+
println!("Warning: --find-by not supported by yarn");
161+
}
162+
}
163+
PackageManagerType::Npm => {
164+
bin_name = "npm".into();
165+
166+
// npm uses 'explain' as primary command
167+
args.push("explain".into());
168+
169+
// npm: --workspace comes after command
170+
if let Some(filters) = options.filters {
171+
for filter in filters {
172+
args.push("--workspace".into());
173+
args.push(filter.clone());
174+
}
175+
}
176+
177+
if options.json {
178+
args.push("--json".into());
179+
}
180+
181+
// Add packages (npm supports multiple packages)
182+
args.extend_from_slice(options.packages);
183+
184+
// Warn about pnpm-specific flags
185+
if options.long {
186+
println!("Warning: --long not supported by npm");
187+
}
188+
if options.parseable {
189+
println!("Warning: --parseable not supported by npm");
190+
}
191+
if options.prod || options.dev {
192+
println!("Warning: --prod/--dev not supported by npm");
193+
}
194+
if options.depth.is_some() {
195+
println!("Warning: --depth not supported by npm");
196+
}
197+
if options.find_by.is_some() {
198+
println!("Warning: --find-by not supported by npm");
199+
}
200+
}
201+
}
202+
203+
// Add pass-through args
204+
if let Some(pass_through_args) = options.pass_through_args {
205+
args.extend_from_slice(pass_through_args);
206+
}
207+
208+
ResolveCommandResult { bin_path: bin_name, args, envs }
209+
}
210+
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use tempfile::{TempDir, tempdir};
215+
use vite_path::AbsolutePathBuf;
216+
use vite_str::Str;
217+
218+
use super::*;
219+
220+
fn create_temp_dir() -> TempDir {
221+
tempdir().expect("Failed to create temp directory")
222+
}
223+
224+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
225+
let temp_dir = create_temp_dir();
226+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
227+
let install_dir = temp_dir_path.join("install");
228+
229+
PackageManager {
230+
client: pm_type,
231+
package_name: pm_type.to_string().into(),
232+
version: Str::from(version),
233+
hash: None,
234+
bin_name: pm_type.to_string().into(),
235+
workspace_root: temp_dir_path.clone(),
236+
install_dir,
237+
}
238+
}
239+
240+
#[test]
241+
fn test_pnpm_why_basic() {
242+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
243+
let packages = vec!["react".to_string()];
244+
let result = pm
245+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
246+
assert_eq!(result.bin_path, "pnpm");
247+
assert_eq!(result.args, vec!["why", "react"]);
248+
}
249+
250+
#[test]
251+
fn test_pnpm_why_multiple_packages() {
252+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
253+
let packages = vec!["react".to_string(), "lodash".to_string()];
254+
let result = pm
255+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
256+
assert_eq!(result.bin_path, "pnpm");
257+
assert_eq!(result.args, vec!["why", "react", "lodash"]);
258+
}
259+
260+
#[test]
261+
fn test_pnpm_why_json() {
262+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
263+
let packages = vec!["react".to_string()];
264+
let result = pm.resolve_why_command(&WhyCommandOptions {
265+
packages: &packages,
266+
json: true,
267+
..Default::default()
268+
});
269+
assert_eq!(result.bin_path, "pnpm");
270+
assert_eq!(result.args, vec!["why", "--json", "react"]);
271+
}
272+
273+
#[test]
274+
fn test_npm_explain_basic() {
275+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
276+
let packages = vec!["react".to_string()];
277+
let result = pm
278+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
279+
assert_eq!(result.bin_path, "npm");
280+
assert_eq!(result.args, vec!["explain", "react"]);
281+
}
282+
283+
#[test]
284+
fn test_npm_explain_multiple_packages() {
285+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
286+
let packages = vec!["react".to_string(), "lodash".to_string()];
287+
let result = pm
288+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
289+
assert_eq!(result.bin_path, "npm");
290+
assert_eq!(result.args, vec!["explain", "react", "lodash"]);
291+
}
292+
293+
#[test]
294+
fn test_npm_explain_with_workspace() {
295+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
296+
let packages = vec!["react".to_string()];
297+
let filters = vec!["app".to_string()];
298+
let result = pm.resolve_why_command(&WhyCommandOptions {
299+
packages: &packages,
300+
filters: Some(&filters),
301+
..Default::default()
302+
});
303+
assert_eq!(result.bin_path, "npm");
304+
assert_eq!(result.args, vec!["explain", "--workspace", "app", "react"]);
305+
}
306+
307+
#[test]
308+
fn test_yarn_why_basic() {
309+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
310+
let packages = vec!["react".to_string()];
311+
let result = pm
312+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
313+
assert_eq!(result.bin_path, "yarn");
314+
assert_eq!(result.args, vec!["why", "react", "--peers"]);
315+
}
316+
317+
#[test]
318+
fn test_yarn_why_with_exclude_peers() {
319+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
320+
let packages = vec!["react".to_string()];
321+
let result = pm.resolve_why_command(&WhyCommandOptions {
322+
packages: &packages,
323+
exclude_peers: true,
324+
..Default::default()
325+
});
326+
assert_eq!(result.bin_path, "yarn");
327+
assert_eq!(result.args, vec!["why", "react"]);
328+
}
329+
330+
#[test]
331+
fn test_yarn1_why_no_peers() {
332+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "1.22.0");
333+
let packages = vec!["react".to_string()];
334+
let result = pm
335+
.resolve_why_command(&WhyCommandOptions { packages: &packages, ..Default::default() });
336+
assert_eq!(result.bin_path, "yarn");
337+
// yarn@1 doesn't support --peers
338+
assert_eq!(result.args, vec!["why", "react"]);
339+
}
340+
341+
#[test]
342+
fn test_pnpm_why_with_filter() {
343+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
344+
let packages = vec!["react".to_string()];
345+
let filters = vec!["app".to_string()];
346+
let result = pm.resolve_why_command(&WhyCommandOptions {
347+
packages: &packages,
348+
filters: Some(&filters),
349+
..Default::default()
350+
});
351+
assert_eq!(result.bin_path, "pnpm");
352+
assert_eq!(result.args, vec!["--filter", "app", "why", "react"]);
353+
}
354+
355+
#[test]
356+
fn test_pnpm_why_with_depth() {
357+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
358+
let packages = vec!["react".to_string()];
359+
let result = pm.resolve_why_command(&WhyCommandOptions {
360+
packages: &packages,
361+
depth: Some(3),
362+
..Default::default()
363+
});
364+
assert_eq!(result.bin_path, "pnpm");
365+
assert_eq!(result.args, vec!["why", "--depth", "3", "react"]);
366+
}
367+
368+
#[test]
369+
fn test_pnpm_why_with_find_by() {
370+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
371+
let packages = vec!["react".to_string()];
372+
let result = pm.resolve_why_command(&WhyCommandOptions {
373+
packages: &packages,
374+
find_by: Some("customFinder"),
375+
..Default::default()
376+
});
377+
assert_eq!(result.bin_path, "pnpm");
378+
assert_eq!(result.args, vec!["why", "--find-by", "customFinder", "react"]);
379+
}
380+
}

0 commit comments

Comments
 (0)