Repository: pesde-pkg/pesde
Pull request #32: feat: add dev flag
Author: login: Stefanuk12
Created: 2025-04-19T22:54:45Z | Merged: 2025-04-25T19:41:59Z

Closes #18 

Linked issue #18: [Feature] Installing only dev-dependencies
I usually include all of my tooling inside of the `dev-dependencies`. In my CI/CD, I would use `pesde install --dev` to install only those dependencies to lint, format, and build my code. However, this isn't possible at the moment.
Linked issue comments: user: login: daimond113
created_at: 2025-01-03T18:23:34Z
body: Thanks for the issue! The idea doesn't seem bad to me. I'll see if I'll be able to include it in 0.6, and if not it'll be included in a later release of 0.6.
author_association: MEMBER

Diff:
diff --git a/docs/src/content/docs/reference/cli.mdx b/docs/src/content/docs/reference/cli.mdx
index 8874d306..9c0aa955 100644
--- a/docs/src/content/docs/reference/cli.mdx
+++ b/docs/src/content/docs/reference/cli.mdx
@@ -110,6 +110,7 @@ Installs dependencies for the current project.
 
 - `--locked`: Whether to error if the lockfile is out of date.
 - `--prod`: Whether to not linking dev dependencies.
+- `--dev`: Whether to only link dev dependencies.
 - `--network-concurrency <CONCURRENCY>`: The number of concurrent network
   requests to make at most. Defaults to 16.
 - `--force`: Whether to force reinstall all packages even if they are already
diff --git a/src/cli/commands/execute.rs b/src/cli/commands/execute.rs
index 24efacd8..fdade01d 100644
--- a/src/cli/commands/execute.rs
+++ b/src/cli/commands/execute.rs
@@ -9,7 +9,7 @@ use console::style;
 use fs_err::tokio as fs;
 use indicatif::MultiProgress;
 use pesde::{
-	download_and_link::DownloadAndLinkOptions,
+	download_and_link::{DownloadAndLinkOptions, InstallDependenciesMode},
 	linking::generator::generate_bin_linking_module,
 	manifest::target::TargetKind,
 	names::{PackageName, PackageNames},
@@ -178,7 +178,7 @@ impl ExecuteCommand {
 						DownloadAndLinkOptions::<CliReporter<Stderr>, ()>::new(reqwest)
 							.reporter(reporter)
 							.refreshed_sources(refreshed_sources)
-							.prod(true),
+							.install_dependencies_mode(InstallDependenciesMode::Prod),
 					)
 					.await
 					.context("failed to download and link dependencies")?;
diff --git a/src/cli/commands/install.rs b/src/cli/commands/install.rs
index c307b359..c811fb32 100644
--- a/src/cli/commands/install.rs
+++ b/src/cli/commands/install.rs
@@ -3,7 +3,7 @@ use crate::cli::{
 	run_on_workspace_members,
 };
 use clap::Args;
-use pesde::Project;
+use pesde::{download_and_link::InstallDependenciesMode, Project};
 use std::num::NonZeroUsize;
 
 #[derive(Debug, Args, Copy, Clone)]
@@ -16,6 +16,10 @@ pub struct InstallCommand {
 	#[arg(long)]
 	prod: bool,
 
+	/// Whether to only install dev dependencies
+	#[arg(long)]
+	dev: bool,
+
 	/// The maximum number of concurrent network requests
 	#[arg(long, default_value = "16")]
 	network_concurrency: NonZeroUsize,
@@ -30,9 +34,16 @@ pub struct InstallCommand {
 struct CallbackError(#[from] anyhow::Error);
 impl InstallCommand {
 	pub async fn run(self, project: Project, reqwest: reqwest::Client) -> anyhow::Result<()> {
+		let install_dependencies_mode = match (self.prod, self.dev) {
+			(true, true) => anyhow::bail!("cannot have both prod and dev flags enabled"),
+			(true, false) => InstallDependenciesMode::Prod,
+			(false, true) => InstallDependenciesMode::Dev,
+			(false, false) => InstallDependenciesMode::All,
+		};
+
 		let options = InstallOptions {
 			locked: self.locked,
-			prod: self.prod,
+			install_dependencies_mode,
 			write: true,
 			network_concurrency: self.network_concurrency,
 			use_lockfile: true,
diff --git a/src/cli/commands/update.rs b/src/cli/commands/update.rs
index ffb065cb..7bf9473a 100644
--- a/src/cli/commands/update.rs
+++ b/src/cli/commands/update.rs
@@ -3,7 +3,7 @@ use crate::cli::{
 	run_on_workspace_members,
 };
 use clap::Args;
-use pesde::Project;
+use pesde::{download_and_link::InstallDependenciesMode, Project};
 use std::num::NonZeroUsize;
 
 #[derive(Debug, Args, Copy, Clone)]
@@ -25,7 +25,7 @@ impl UpdateCommand {
 	pub async fn run(self, project: Project, reqwest: reqwest::Client) -> anyhow::Result<()> {
 		let options = InstallOptions {
 			locked: false,
-			prod: false,
+			install_dependencies_mode: InstallDependenciesMode::All,
 			write: !self.no_install,
 			network_concurrency: self.network_concurrency,
 			use_lockfile: false,
diff --git a/src/cli/install.rs b/src/cli/install.rs
index e7b290f9..6c9a6cce 100644
--- a/src/cli/install.rs
+++ b/src/cli/install.rs
@@ -10,7 +10,7 @@ use anyhow::Context as _;
 use console::style;
 use fs_err::tokio as fs;
 use pesde::{
-	download_and_link::{DownloadAndLinkHooks, DownloadAndLinkOptions},
+	download_and_link::{DownloadAndLinkHooks, DownloadAndLinkOptions, InstallDependenciesMode},
 	engine::EngineKind,
 	graph::{DependencyGraph, DependencyGraphWithTarget},
 	lockfile::Lockfile,
@@ -123,7 +123,7 @@ impl DownloadAndLinkHooks for InstallHooks {
 #[derive(Debug, Clone, Copy)]
 pub struct InstallOptions {
 	pub locked: bool,
-	pub prod: bool,
+	pub install_dependencies_mode: InstallDependenciesMode,
 	pub write: bool,
 	pub use_lockfile: bool,
 	pub network_concurrency: NonZeroUsize,
@@ -285,7 +285,7 @@ pub async fn install(
 							.reporter(reporter)
 							.hooks(hooks)
 							.refreshed_sources(refreshed_sources.clone())
-							.prod(options.prod)
+							.install_dependencies_mode(options.install_dependencies_mode)
 							.network_concurrency(options.network_concurrency)
 							.force(options.force || has_irrecoverable_changes),
 					)
diff --git a/src/download_and_link.rs b/src/download_and_link.rs
index bfe6ea3a..e2d903d6 100644
--- a/src/download_and_link.rs
+++ b/src/download_and_link.rs
@@ -15,9 +15,9 @@ use crate::{
 };
 use fs_err::tokio as fs;
 use futures::TryStreamExt as _;
+
 use std::{
-	borrow::Cow,
-	collections::HashMap,
+	collections::{HashMap, VecDeque},
 	convert::Infallible,
 	future::{self, Future},
 	num::NonZeroUsize,
@@ -65,6 +65,28 @@ impl DownloadAndLinkHooks for () {
 	type Error = Infallible;
 }
 
+/// Options for which dependencies to install.
+#[derive(Debug, Clone, Copy)]
+pub enum InstallDependenciesMode {
+	/// Install all dependencies
+	All,
+	/// Install all dependencies, then remove [DependencyType::Dev] dependencies
+	Prod,
+	/// Only install dependencies which are [DependencyType::Dev]
+	Dev,
+}
+
+impl InstallDependenciesMode {
+	fn fits(self, dep_ty: DependencyType) -> bool {
+		match (self, dep_ty) {
+			(InstallDependenciesMode::Prod, DependencyType::Dev) => false,
+			(InstallDependenciesMode::Dev, dep_ty) => dep_ty == DependencyType::Dev,
+
+			_ => true,
+		}
+	}
+}
+
 /// Options for downloading and linking.
 #[derive(Debug)]
 pub struct DownloadAndLinkOptions<Reporter = (), Hooks = ()> {
@@ -76,8 +98,8 @@ pub struct DownloadAndLinkOptions<Reporter = (), Hooks = ()> {
 	pub hooks: Option<Arc<Hooks>>,
 	/// The refreshed sources.
 	pub refreshed_sources: RefreshedSources,
-	/// Whether to skip dev dependencies.
-	pub prod: bool,
+	/// Which dependencies to install.
+	pub install_dependencies_mode: InstallDependenciesMode,
 	/// The max number of concurrent network requests.
 	pub network_concurrency: NonZeroUsize,
 	/// Whether to re-install all dependencies even if they are already installed
@@ -97,7 +119,7 @@ where
 			reporter: None,
 			hooks: None,
 			refreshed_sources: Default::default(),
-			prod: false,
+			install_dependencies_mode: InstallDependenciesMode::All,
 			network_concurrency: NonZeroUsize::new(16).unwrap(),
 			force: false,
 		}
@@ -124,10 +146,13 @@ where
 		self
 	}
 
-	/// Sets whether to skip dev dependencies.
+	/// Sets which dependencies to install
 	#[must_use]
-	pub fn prod(mut self, prod: bool) -> Self {
-		self.prod = prod;
+	pub fn install_dependencies_mode(
+		mut self,
+		install_dependencies: InstallDependenciesMode,
+	) -> Self {
+		self.install_dependencies_mode = install_dependencies;
 		self
 	}
 
@@ -153,7 +178,7 @@ impl Clone for DownloadAndLinkOptions {
 			reporter: self.reporter.clone(),
 			hooks: self.hooks.clone(),
 			refreshed_sources: self.refreshed_sources.clone(),
-			prod: self.prod,
+			install_dependencies_mode: self.install_dependencies_mode,
 			network_concurrency: self.network_concurrency,
 			force: self.force,
 		}
@@ -162,7 +187,7 @@ impl Clone for DownloadAndLinkOptions {
 
 impl Project {
 	/// Downloads a graph of dependencies and links them in the correct order
-	#[instrument(skip_all, fields(prod = options.prod), level = "debug")]
+	#[instrument(skip_all, fields(install_dependencies = debug(options.install_dependencies_mode)), level = "debug")]
 	pub async fn download_and_link<Reporter, Hooks>(
 		&self,
 		graph: &DependencyGraph,
@@ -177,7 +202,7 @@ impl Project {
 			reporter,
 			hooks,
 			refreshed_sources,
-			prod,
+			install_dependencies_mode,
 			network_concurrency,
 			force,
 		} = options;
@@ -219,16 +244,41 @@ impl Project {
 				download_graph_options = download_graph_options.reporter(reporter);
 			}
 
+			let correct_deps = if matches!(install_dependencies_mode, InstallDependenciesMode::All)
+			{
+				graph.clone()
+			} else {
+				let mut queue = graph
+					.iter()
+					.filter(|(_, node)| {
+						node.direct.is_some() && install_dependencies_mode.fits(node.resolved_ty)
+					})
+					.collect::<VecDeque<_>>();
+
+				let mut correct_deps = DependencyGraph::new();
+				while let Some((id, node)) = queue.pop_front() {
+					if correct_deps.insert(id.clone(), node.clone()).is_some() {
+						// prevent an infinite loop with recursive dependencies
+						continue;
+					}
+
+					node.dependencies
+						.keys()
+						.filter_map(|id| graph.get(id).map(|node| (id, node)))
+						.for_each(|x| queue.push_back(x));
+				}
+
+				correct_deps
+			};
+
 			let mut downloaded_graph = DependencyGraph::new();
 
 			let graph_to_download = if force {
-				Cow::Borrowed(graph)
+				correct_deps
 			} else {
-				let mut tasks = graph
-					.iter()
+				let mut tasks = correct_deps
+					.into_iter()
 					.map(|(id, node)| {
-						let id = id.clone();
-						let node = node.clone();
 						let container_folder =
 							node.container_folder_from_project(&id, self, manifest.target.kind());
 
@@ -249,7 +299,7 @@ impl Project {
 					graph_to_download.insert(id, node);
 				}
 
-				Cow::Owned(graph_to_download)
+				graph_to_download
 			};
 
 			let downloaded = self
@@ -418,11 +468,7 @@ impl Project {
 				.map_err(errors::DownloadAndLinkError::Hook)?;
 		}
 
-		if prod {
-			graph.retain(|_, node| node.node.resolved_ty != DependencyType::Dev);
-		}
-
-		if prod || !force {
+		if matches!(install_dependencies_mode, InstallDependenciesMode::Prod) || !force {
 			self.remove_unused(&graph).await?;
 		}
 


Reviews:
user: login: daimond113
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T06:28:52Z
user: login: Stefanuk12
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T19:14:47Z
user: login: daimond113
body: Do these changes support one dependency resolving to be a descendant of multiple direct dependencies, especially if they're of mixed dependency types?
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T19:31:34Z
user: login: daimond113
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-24T22:04:29Z
user: login: daimond113
body: Thank you!
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T19:41:37Z

Review comments:
diff_hunk: @@ -226,15 +241,30 @@ impl Project {
 			} else {
 				let mut tasks = graph
 					.iter()
-					.map(|(id, node)| {
+					.filter_map(|(id, node)| {
 						let id = id.clone();
 						let node = node.clone();
 						let container_folder =
 							node.container_folder_from_project(&id, self, manifest.target.kind());
 
-						async move {
+						match install_dependencies_mode {
+							InstallDependenciesMode::Prod
+								if node.resolved_ty == DependencyType::Dev =>
+							{
+								return None
+							}
+							InstallDependenciesMode::Dev
+								if node.resolved_ty != DependencyType::Dev
+									&& node.direct.is_some() =>
path: src/download_and_link.rs
user: login: daimond113
body: This'll include the dependencies of non-dev dependencies.
created_at: 2025-04-24T06:27:23Z
author_association: MEMBER
diff_hunk: @@ -418,11 +448,9 @@ impl Project {
 				.map_err(errors::DownloadAndLinkError::Hook)?;
 		}
 
-		if prod {
-			graph.retain(|_, node| node.node.resolved_ty != DependencyType::Dev);
-		}
+		let graph = Arc::into_inner(graph).unwrap();
path: src/download_and_link.rs
user: login: daimond113
body: ```suggestion
```
`graph` is no longer in an Arc.
created_at: 2025-04-24T06:28:41Z
author_association: MEMBER
diff_hunk: @@ -226,15 +241,30 @@ impl Project {
 			} else {
 				let mut tasks = graph
 					.iter()
-					.map(|(id, node)| {
+					.filter_map(|(id, node)| {
 						let id = id.clone();
 						let node = node.clone();
 						let container_folder =
 							node.container_folder_from_project(&id, self, manifest.target.kind());
 
-						async move {
+						match install_dependencies_mode {
+							InstallDependenciesMode::Prod
+								if node.resolved_ty == DependencyType::Dev =>
+							{
+								return None
+							}
+							InstallDependenciesMode::Dev
+								if node.resolved_ty != DependencyType::Dev
+									&& node.direct.is_some() =>
path: src/download_and_link.rs
user: login: Stefanuk12
body: I fixed this, maybe a little inefficient but let me know what you think.
created_at: 2025-04-24T19:14:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -226,15 +261,41 @@ impl Project {
 			} else {
 				let mut tasks = graph
 					.iter()
-					.map(|(id, node)| {
+					.filter_map(|(id, node)| {
 						let id = id.clone();
 						let node = node.clone();
 						let container_folder =
 							node.container_folder_from_project(&id, self, manifest.target.kind());
 
-						async move {
-							return (id, node, fs::metadata(&container_folder).await.is_ok());
+						async fn make_fut(
+							id: PackageId,
+							node: DependencyGraphNode,
+							container_folder: PathBuf,
+						) -> (PackageId, DependencyGraphNode, bool) {
+							(id, node, fs::metadata(&container_folder).await.is_ok())
 						}
+
+						if node.direct.is_some() && install_dependencies_mode == node.resolved_ty {
+							return Some(make_fut(id, node, container_folder));
+						}
+
+						// not a direct dependency, check if it's parent is and matches the install mode
+						// todo: optimise this maybe. many iterations through graph can add up if graph big
path: src/download_and_link.rs
user: login: daimond113
body: ```suggestion
						// not a direct dependency, check if its parent is and matches the install mode
						// TODO: optimise this maybe. many iterations through the graph can add up if the graph's big
```
created_at: 2025-04-24T19:23:53Z
author_association: MEMBER
diff_hunk: @@ -65,6 +65,31 @@ impl DownloadAndLinkHooks for () {
 	type Error = Infallible;
 }
 
+/// Options for which dependencies to install.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
+pub enum InstallDependenciesMode {
+	/// Install all dependencies.
+	#[default]
+	All,
+	/// Install only dependencies, not dev_dependencies.
+	Prod,
+	/// Install only dev_dependencies.
+	Dev,
+}
+impl PartialEq<DependencyType> for InstallDependenciesMode {
+	fn eq(&self, other: &DependencyType) -> bool {
path: src/download_and_link.rs
user: login: daimond113
body: ```suggestion

impl InstallDependenciesMode {
	fn fits(&self, dep_ty: &DependencyType) -> bool {
```

Let's not use PartialEq for this.
created_at: 2025-04-24T19:26:03Z
author_association: MEMBER
diff_hunk: @@ -65,6 +65,31 @@ impl DownloadAndLinkHooks for () {
 	type Error = Infallible;
 }
 
+/// Options for which dependencies to install.
+#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
+pub enum InstallDependenciesMode {
+	/// Install all dependencies.
+	#[default]
path: src/download_and_link.rs
user: login: daimond113
body: ```suggestion
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallDependenciesMode {
	/// Install all dependencies.
```
Let's not implement more traits than useful.
created_at: 2025-04-24T19:27:45Z
author_association: MEMBER
diff_hunk: @@ -65,6 +65,30 @@ impl DownloadAndLinkHooks for () {
 	type Error = Infallible;
 }
 
+/// Options for which dependencies to install.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InstallDependenciesMode {
+	/// Install all dependencies.
+	All,
+	/// Install only dependencies, not dev_dependencies.
+	Prod,
+	/// Install only dev_dependencies.
+	Dev,
+}
+impl InstallDependenciesMode {
+	fn fits(&self, dep_ty: &DependencyType) -> bool {
path: src/download_and_link.rs
user: login: daimond113
body: ```suggestion

impl InstallDependenciesMode {
	fn fits(&self, dep_ty: DependencyType) -> bool {
```
Since `DependencyType` has the same size as a pointer let's just take ownership.
created_at: 2025-04-24T22:04:16Z
author_association: MEMBER

PR comments:
user: login: daimond113
created_at: 2025-04-19T23:01:01Z
body: This PR avoids the whole point of having a separate dev dependency only install, since it still downloads all dependencies and then removes non-dev ones.
author_association: MEMBER
user: login: Stefanuk12
created_at: 2025-04-19T23:22:01Z
body: Looks like I misinterpreted the code, sorry about that one. Will fix.
author_association: CONTRIBUTOR
user: login: Stefanuk12
created_at: 2025-04-24T21:09:28Z
body: I made the assumption that a direct dependency is a dependency defined in our `pesde.toml`. The code should filter all (dev) dependencies that are apart of the dependency tree for each of our dependencies, if that makes sense.
author_association: CONTRIBUTOR
user: login: daimond113
created_at: 2025-04-24T21:44:28Z
body: Yes, that's what the direct field represents. It is important to remember this is a dependency graph and not a tree.

From what I understand, in case of a graph where prod dependency A and dev dependency B both depend on a dependency C your code will only check one direct dependency.
author_association: MEMBER