use std::{collections::HashMap, fs::File, io::BufReader, path::PathBuf};
use cargo_metadata::semver::Version;
use clap::{Args, Subcommand};
use printable_shell_command::{
ArgumentLineWrapping, FormattingOptions, PrintableShellCommand, ShellPrintableWithOptions,
};
use serde::{Deserialize, Serialize};
use crate::{
commands::version::{detect_ecosystem_by_getting_version, CommitOperationArgs},
common::{
commit_wrapped_operation::CommitWrappedOperation,
ecosystem::EcosystemArgs,
inference::get_stdout,
package_manager::{PackageManager, PackageManagerArgs},
},
};
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash)]
struct DependencyName(String);
impl From<String> for DependencyName {
fn from(value: String) -> Self {
Self(value)
}
}
#[derive(Args, Debug)]
pub(crate) struct DependenciesArgs {
#[command(flatten)]
ecosystem_args: EcosystemArgs,
#[command(flatten)]
package_manager_args: PackageManagerArgs,
#[command(subcommand)]
command: DependenciesCommand,
}
#[derive(Debug, Subcommand)]
enum DependenciesCommand {
Roll(DependenciesRollCommandArgs),
}
#[derive(Args, Debug)]
pub(crate) struct DependenciesRollCommandArgs {
#[command(flatten)]
roll_args: DependenciesRollArgs,
#[command(flatten)]
common_args: DependenciesCommandCommonArgs,
}
#[derive(Args, Debug)]
pub(crate) struct DependenciesRollArgs {
dependency_name: DependencyName,
#[clap(long)]
pin_exact_version: bool,
}
#[derive(Args, Debug)]
pub(crate) struct DependenciesCommandCommonArgs {
#[command(flatten)]
commit_args: CommitOperationArgs,
}
#[derive(Debug)]
enum NpmDependencyType {
Prod,
Dev,
Peer,
Optional,
}
impl NpmDependencyType {
fn all_types() -> &'static [NpmDependencyType] {
&[Self::Prod, Self::Dev, Self::Peer, Self::Optional]
}
fn npm_install_arg(&self) -> &str {
match self {
NpmDependencyType::Prod => "--save",
NpmDependencyType::Dev => "--save-dev",
NpmDependencyType::Peer => "--save-peer",
NpmDependencyType::Optional => "--save-optional",
}
}
fn bun_add_arg(&self) -> Option<&str> {
match self {
NpmDependencyType::Prod => None,
NpmDependencyType::Dev => Some("--dev"),
NpmDependencyType::Peer => Some("--peer"),
NpmDependencyType::Optional => Some("--optional"),
}
}
}
type DependencyInfo = Option<HashMap<DependencyName, String>>;
#[derive(Deserialize)]
struct PackageJSONSubset {
dependencies: DependencyInfo,
#[serde(rename = "devDependencies")]
dev_dependencies: DependencyInfo,
#[serde(rename = "peerDependencies")]
peer_dependencies: DependencyInfo,
#[serde(rename = "optionalDependencies")]
optional_dependencies: DependencyInfo,
}
impl PackageJSONSubset {
fn get_dependencies_of_type(&self, t: &NpmDependencyType) -> &DependencyInfo {
match t {
NpmDependencyType::Prod => &self.dependencies,
NpmDependencyType::Dev => &self.dev_dependencies,
NpmDependencyType::Peer => &self.peer_dependencies,
NpmDependencyType::Optional => &self.optional_dependencies,
}
}
fn has_dependency_of_type(
&self,
npm_dependency_type: &NpmDependencyType,
dependency_name: &DependencyName,
) -> bool {
let v = self.get_dependencies_of_type(npm_dependency_type);
if let Some(v) = v {
if v.contains_key(dependency_name) {
return true;
}
}
false
}
}
fn must_get_package_json() -> PackageJSONSubset {
let mut npm_command = PrintableShellCommand::new("npm");
npm_command.args(["root"]);
let node_modules_folder = get_stdout(npm_command).unwrap();
let package_json_path = PathBuf::from(node_modules_folder)
.parent()
.unwrap()
.join("package.json");
let file = File::open(package_json_path).unwrap();
let reader = BufReader::new(file);
serde_json::from_reader(reader).unwrap()
}
fn npm_show_version(dependency_name: &DependencyName) -> Version {
let mut npm_command = PrintableShellCommand::new("npm");
npm_command.args(["show", "--", &dependency_name.0, "version"]);
Version::parse(get_stdout(npm_command).unwrap().trim()).unwrap()
}
fn npm_package_contraint_arg(
dependencies_roll_args: &DependenciesRollArgs,
new_version: &Version,
) -> String {
if dependencies_roll_args.pin_exact_version {
format!(
"{}@={}",
&dependencies_roll_args.dependency_name.0, new_version
)
} else if new_version.major == 0 {
format!(
"{}@>={}",
&dependencies_roll_args.dependency_name.0, new_version
)
} else {
format!(
"{}@^{}",
&dependencies_roll_args.dependency_name.0, new_version
)
}
}
fn npm_install(
dependency_type: &NpmDependencyType,
dependencies_roll_args: &DependenciesRollArgs,
new_version: &Version,
) -> String {
let mut npm_command = PrintableShellCommand::new("npm");
npm_command.args([
"install",
dependency_type.npm_install_arg(),
"--",
&npm_package_contraint_arg(dependencies_roll_args, new_version),
]);
let command_string = npm_command
.printable_invocation_string_with_options(FormattingOptions {
argument_line_wrapping: Some(ArgumentLineWrapping::Inline),
..Default::default()
})
.unwrap();
let _ = get_stdout(npm_command).unwrap();
command_string
}
type CommandStringWithNote = (String, Option<String>);
fn try_bun_add_for_roll(
dependency_type: &NpmDependencyType,
dependencies_roll_args: &DependenciesRollArgs,
new_version: &Version,
) -> Result<CommandStringWithNote, ()> {
let mut bun_add_command = PrintableShellCommand::new("bun");
bun_add_command.arg("add");
if let Some(arg) = dependency_type.bun_add_arg() {
bun_add_command.arg(arg);
}
bun_add_command.arg("--");
let dependency_arg = npm_package_contraint_arg(dependencies_roll_args, new_version);
bun_add_command.arg(&dependency_arg);
let mut bun_dedupe_command = PrintableShellCommand::new("bun");
bun_dedupe_command.args(["x", "--package", "bun-dedupe@0.0.5", "dedupe", "--"]);
let mut bun_install_command = PrintableShellCommand::new("bun");
bun_install_command.args(["install"]);
let command_string = [&bun_add_command, &bun_dedupe_command, &bun_install_command]
.map(|v| {
v.printable_invocation_string_with_options(FormattingOptions {
argument_line_wrapping: Some(ArgumentLineWrapping::Inline),
..Default::default()
})
.unwrap()
})
.join(" && ");
let Some(_) = get_stdout(bun_add_command) else {
return Err(());
};
let Some(_) = get_stdout(bun_dedupe_command) else {
return Err(());
};
let Some(_) = get_stdout(bun_install_command) else {
return Err(());
};
Ok((
command_string,
Some("Deduplicating deps like this is the current best workaround for: https://github.com/oven-sh/bun/issues/1343".to_owned()),
))
}
fn bun_pm_cache_rm() -> Result<(), ()> {
let mut bun_command = PrintableShellCommand::new("bun");
bun_command.args(["pm", "cache", "rm"]);
let Some(_) = get_stdout(bun_command) else {
return Err(());
};
Ok(())
}
fn bun_add_for_roll(
dependency_type: &NpmDependencyType,
dependencies_roll_args: &DependenciesRollArgs,
new_version: &Version,
) -> CommandStringWithNote {
if let Ok(s) = try_bun_add_for_roll(dependency_type, dependencies_roll_args, new_version) {
return s;
};
eprintln!(
"Updating the dependency version failed. Clearing `bun`'s cache and trying one more time."
);
bun_pm_cache_rm().unwrap();
try_bun_add_for_roll(dependency_type, dependencies_roll_args, new_version).unwrap()
}
pub(crate) fn dependencies_command(dependencies_args: DependenciesArgs) -> Result<(), String> {
match dependencies_args.command {
DependenciesCommand::Roll(dependencies_command_args) => {
let package_manager = match &dependencies_args.package_manager_args.package_manager {
Some(package_manager) => package_manager.clone(),
None => {
let Some((ecosystem, _)) =
detect_ecosystem_by_getting_version(&dependencies_args.ecosystem_args)
else {
return Err("Could not detect ecosystem.".to_owned());
};
let Some(package_manager) =
PackageManager::auto_detect_preferred_package_manager_for_ecosystem(
ecosystem,
)
else {
return Err("Could not detect package manager.".to_owned());
};
package_manager
}
};
let dependency_name = &dependencies_command_args.roll_args.dependency_name;
match package_manager {
PackageManager::Npm | PackageManager::Bun => {
let package_json = must_get_package_json();
let new_version = &npm_show_version(dependency_name);
let mut any_rolled = false;
for npm_dependency_type in NpmDependencyType::all_types() {
if package_json.has_dependency_of_type(npm_dependency_type, dependency_name)
{
let commit_wrapped_operation = CommitWrappedOperation::try_from(
&dependencies_command_args.common_args.commit_args,
)
.unwrap();
commit_wrapped_operation
.perform_operation(&|| {
let (command, note) = if package_manager == PackageManager::Npm
{
(
npm_install(
npm_dependency_type,
&dependencies_command_args.roll_args,
new_version,
),
None,
)
} else {
bun_add_for_roll(
npm_dependency_type,
&dependencies_command_args.roll_args,
new_version,
)
};
println!("{}", command);
Ok(format!(
"`{}` (roll){}",
command,
note.map(|s| format!("\n\n{}", s)).unwrap_or_default()
))
})
.unwrap();
any_rolled = true;
}
}
if !any_rolled {
eprintln!(
"⚠️ Must already have as a dependency in order to roll versions: {}",
&dependency_name.0
)
}
Ok(())
}
package_manager => Err(format!(
"Dependency rolling is not implemented for package manager: {}",
package_manager
)),
}
}
}
}