Skip to content

Commit cee3aad

Browse files
committed
feat(pm): add dedupe commands
1 parent de47ed9 commit cee3aad

8 files changed

Lines changed: 1191 additions & 2 deletions

File tree

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 dedupe command.
11+
#[derive(Debug, Default)]
12+
pub struct DedupeCommandOptions<'a> {
13+
pub check: bool,
14+
pub pass_through_args: Option<&'a [String]>,
15+
}
16+
17+
impl PackageManager {
18+
/// Run the dedupe command with the package manager.
19+
/// Return the exit status of the command.
20+
#[must_use]
21+
pub async fn run_dedupe_command(
22+
&self,
23+
options: &DedupeCommandOptions<'_>,
24+
cwd: impl AsRef<AbsolutePath>,
25+
) -> Result<ExitStatus, Error> {
26+
let resolve_command = self.resolve_dedupe_command(options);
27+
run_command(&resolve_command.bin_path, &resolve_command.args, &resolve_command.envs, cwd)
28+
.await
29+
}
30+
31+
/// Resolve the dedupe command.
32+
#[must_use]
33+
pub fn resolve_dedupe_command(&self, options: &DedupeCommandOptions) -> ResolveCommandResult {
34+
let bin_name: String;
35+
let envs = HashMap::from([("PATH".to_string(), format_path_env(self.get_bin_prefix()))]);
36+
let mut args: Vec<String> = Vec::new();
37+
38+
match self.client {
39+
PackageManagerType::Pnpm => {
40+
bin_name = "pnpm".into();
41+
args.push("dedupe".into());
42+
43+
// pnpm uses --check for dry-run
44+
if options.check {
45+
args.push("--check".into());
46+
}
47+
}
48+
PackageManagerType::Yarn => {
49+
bin_name = "yarn".into();
50+
args.push("dedupe".into());
51+
52+
// yarn@2+ supports --check
53+
if options.check {
54+
args.push("--check".into());
55+
}
56+
}
57+
PackageManagerType::Npm => {
58+
bin_name = "npm".into();
59+
args.push("dedupe".into());
60+
61+
if options.check {
62+
args.push("--dry-run".into());
63+
}
64+
}
65+
}
66+
67+
// Add pass-through args
68+
if let Some(pass_through_args) = options.pass_through_args {
69+
args.extend_from_slice(pass_through_args);
70+
}
71+
72+
ResolveCommandResult { bin_path: bin_name, args, envs }
73+
}
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use tempfile::{TempDir, tempdir};
79+
use vite_path::AbsolutePathBuf;
80+
use vite_str::Str;
81+
82+
use super::*;
83+
84+
fn create_temp_dir() -> TempDir {
85+
tempdir().expect("Failed to create temp directory")
86+
}
87+
88+
fn create_mock_package_manager(pm_type: PackageManagerType, version: &str) -> PackageManager {
89+
let temp_dir = create_temp_dir();
90+
let temp_dir_path = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
91+
let install_dir = temp_dir_path.join("install");
92+
93+
PackageManager {
94+
client: pm_type,
95+
package_name: pm_type.to_string().into(),
96+
version: Str::from(version),
97+
hash: None,
98+
bin_name: pm_type.to_string().into(),
99+
workspace_root: temp_dir_path.clone(),
100+
install_dir,
101+
}
102+
}
103+
104+
#[test]
105+
fn test_pnpm_dedupe_basic() {
106+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
107+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
108+
assert_eq!(result.bin_path, "pnpm");
109+
assert_eq!(result.args, vec!["dedupe"]);
110+
}
111+
112+
#[test]
113+
fn test_pnpm_dedupe_check() {
114+
let pm = create_mock_package_manager(PackageManagerType::Pnpm, "10.0.0");
115+
let result =
116+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
117+
assert_eq!(result.bin_path, "pnpm");
118+
assert_eq!(result.args, vec!["dedupe", "--check"]);
119+
}
120+
121+
#[test]
122+
fn test_npm_dedupe_basic() {
123+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
124+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
125+
assert_eq!(result.args, vec!["dedupe"]);
126+
assert_eq!(result.bin_path, "npm");
127+
}
128+
129+
#[test]
130+
fn test_npm_dedupe_check() {
131+
let pm = create_mock_package_manager(PackageManagerType::Npm, "11.0.0");
132+
let result =
133+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
134+
assert_eq!(result.args, vec!["dedupe", "--dry-run"]);
135+
assert_eq!(result.bin_path, "npm");
136+
}
137+
138+
#[test]
139+
fn test_yarn_dedupe_basic() {
140+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
141+
let result = pm.resolve_dedupe_command(&DedupeCommandOptions { ..Default::default() });
142+
assert_eq!(result.args, vec!["dedupe"]);
143+
assert_eq!(result.bin_path, "yarn");
144+
}
145+
146+
#[test]
147+
fn test_yarn_dedupe_check() {
148+
let pm = create_mock_package_manager(PackageManagerType::Yarn, "4.0.0");
149+
let result =
150+
pm.resolve_dedupe_command(&DedupeCommandOptions { check: true, ..Default::default() });
151+
assert_eq!(result.args, vec!["dedupe", "--check"]);
152+
assert_eq!(result.bin_path, "yarn");
153+
}
154+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod add;
2+
pub mod dedupe;
23
mod install;
34
pub mod remove;
45
pub mod update;

packages/cli/binding/src/cli.rs

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use vite_task::{
1919

2020
use crate::commands::{
2121
add::AddCommand,
22+
dedupe::DedupeCommand,
2223
doc::doc as doc_cmd,
2324
fmt::{FmtConfig, fmt},
2425
install::InstallCommand,
@@ -272,6 +273,17 @@ pub enum Commands {
272273
/// Packages to update (optional - updates all if omitted)
273274
packages: Vec<String>,
274275

276+
/// Additional arguments to pass through to the package manager
277+
#[arg(last = true, allow_hyphen_values = true)]
278+
pass_through_args: Option<Vec<String>>,
279+
},
280+
/// Deduplicate dependencies by removing older versions
281+
#[command(disable_help_flag = true, alias = "ddp")]
282+
Dedupe {
283+
/// Check if deduplication would make changes (pnpm: --check, npm: --dry-run)
284+
#[arg(long)]
285+
check: bool,
286+
275287
/// Additional arguments to pass through to the package manager
276288
#[arg(last = true, allow_hyphen_values = true)]
277289
pass_through_args: Option<Vec<String>>,
@@ -281,7 +293,13 @@ pub enum Commands {
281293
impl Commands {
282294
/// Check if this command is a package manager command that should skip auto-install
283295
pub fn is_package_manager_command(&self) -> bool {
284-
matches!(self, Commands::Install { .. } | Commands::Add { .. } | Commands::Remove { .. })
296+
matches!(
297+
self,
298+
Commands::Install { .. }
299+
| Commands::Add { .. }
300+
| Commands::Remove { .. }
301+
| Commands::Dedupe { .. }
302+
)
285303
}
286304
}
287305

@@ -703,6 +721,11 @@ pub async fn main<
703721
.await?;
704722
return Ok(exit_status);
705723
}
724+
Commands::Dedupe { check, pass_through_args } => {
725+
let exit_status =
726+
DedupeCommand::new(cwd).execute(*check, pass_through_args.as_deref()).await?;
727+
return Ok(exit_status);
728+
}
706729
};
707730

708731
let execution_summary_dir = EXECUTION_SUMMARY_DIR.as_path();
@@ -2320,4 +2343,67 @@ mod tests {
23202343
}
23212344
}
23222345
}
2346+
2347+
mod dedupe_command_tests {
2348+
use super::*;
2349+
2350+
#[test]
2351+
fn test_args_dedupe_command_basic() {
2352+
let args = Args::try_parse_from(&["vite-plus", "dedupe"]).unwrap();
2353+
if let Commands::Dedupe { check, .. } = &args.commands {
2354+
assert!(!check);
2355+
} else {
2356+
panic!("Expected Dedupe command");
2357+
}
2358+
}
2359+
2360+
#[test]
2361+
fn test_args_dedupe_command_with_alias() {
2362+
let args = Args::try_parse_from(&["vite-plus", "ddp"]).unwrap();
2363+
assert!(matches!(args.commands, Commands::Dedupe { .. }));
2364+
}
2365+
2366+
#[test]
2367+
fn test_args_dedupe_command_with_check() {
2368+
let args = Args::try_parse_from(&["vite-plus", "dedupe", "--check"]).unwrap();
2369+
if let Commands::Dedupe { check, .. } = &args.commands {
2370+
assert!(check);
2371+
} else {
2372+
panic!("Expected Dedupe command");
2373+
}
2374+
}
2375+
2376+
#[test]
2377+
fn test_args_dedupe_command_with_pass_through_args() {
2378+
let args = Args::try_parse_from(&[
2379+
"vite-plus",
2380+
"dedupe",
2381+
"--",
2382+
"--some-flag",
2383+
"--another-flag",
2384+
])
2385+
.unwrap();
2386+
if let Commands::Dedupe { pass_through_args, .. } = &args.commands {
2387+
assert_eq!(
2388+
pass_through_args,
2389+
&Some(vec!["--some-flag".to_string(), "--another-flag".to_string()])
2390+
);
2391+
} else {
2392+
panic!("Expected Dedupe command");
2393+
}
2394+
}
2395+
2396+
#[test]
2397+
fn test_args_dedupe_command_with_check_and_pass_through() {
2398+
let args =
2399+
Args::try_parse_from(&["vite-plus", "dedupe", "--check", "--", "--custom-flag"])
2400+
.unwrap();
2401+
if let Commands::Dedupe { check, pass_through_args, .. } = &args.commands {
2402+
assert!(check);
2403+
assert_eq!(pass_through_args, &Some(vec!["--custom-flag".to_string()]));
2404+
} else {
2405+
panic!("Expected Dedupe command");
2406+
}
2407+
}
2408+
}
23232409
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
use std::process::ExitStatus;
2+
3+
use vite_package_manager::{
4+
commands::dedupe::DedupeCommandOptions, package_manager::PackageManager,
5+
};
6+
use vite_path::AbsolutePathBuf;
7+
8+
use crate::Error;
9+
10+
/// Dedupe command for deduplicating dependencies by removing older versions.
11+
///
12+
/// This command automatically detects the package manager and translates
13+
/// the dedupe command to the appropriate package manager-specific syntax.
14+
pub struct DedupeCommand {
15+
cwd: AbsolutePathBuf,
16+
}
17+
18+
impl DedupeCommand {
19+
pub fn new(cwd: AbsolutePathBuf) -> Self {
20+
Self { cwd }
21+
}
22+
23+
pub async fn execute(
24+
self,
25+
check: bool,
26+
pass_through_args: Option<&[String]>,
27+
) -> Result<ExitStatus, Error> {
28+
// Detect package manager
29+
let package_manager = PackageManager::builder(&self.cwd).build().await?;
30+
31+
let dedupe_command_options = DedupeCommandOptions { check, pass_through_args };
32+
package_manager.run_dedupe_command(&dedupe_command_options, &self.cwd).await
33+
}
34+
}
35+
36+
#[cfg(test)]
37+
mod tests {
38+
use super::*;
39+
40+
#[test]
41+
fn test_dedupe_command_new() {
42+
let workspace_root = if cfg!(windows) {
43+
AbsolutePathBuf::new("C:\\test".into()).unwrap()
44+
} else {
45+
AbsolutePathBuf::new("/test".into()).unwrap()
46+
};
47+
48+
let cmd = DedupeCommand::new(workspace_root.clone());
49+
assert_eq!(cmd.cwd, workspace_root);
50+
}
51+
}

packages/cli/binding/src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub(crate) mod add;
2+
pub(crate) mod dedupe;
23
pub(crate) mod doc;
34
pub(crate) mod fmt;
45
pub(crate) mod install;

packages/cli/snap-tests/exit-non-zero-on-cmd-not-exists/snap.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[2]> vite command-not-exists # should exit with non-zero code
22
error: 'vite' requires a subcommand but one was not provided
3-
[subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, help]
3+
[subcommands: run, lint, fmt, build, test, lib, dev, doc, cache, install, i, add, remove, rm, un, uninstall, update, up, dedupe, ddp, help]
44

55
Usage: vite [OPTIONS] [TASK] [-- <TASK_ARGS>...] <COMMAND>
66

packages/global/snap-tests/cli-helper-message/snap.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Commands:
1515
add Add packages to dependencies
1616
remove Remove packages from dependencies
1717
update Update packages to their latest versions
18+
dedupe Deduplicate dependencies by removing older versions
1819
help Print this message or the help of the given subcommand(s)
1920

2021
Arguments:

0 commit comments

Comments
 (0)