diff --git a/CHANGELOG.md b/CHANGELOG.md index ad704ec2f..26b2ec9c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,9 @@ #### Updates +- Added a `detect-strategy` setting to `~/.proto/config.toml` to configure which strategy to use when detecting a version. Accepts: + - `first-available` (default) - Will use the first available version that is found. Either from `.prototools` or a tool specific file (`.nvmrc`, etc). + - `prefer-prototools` - Prefer a `.prototools` version, even if found in a parent directory. If none found, falls back to tool specific file. - Added support to plugins to ignore certain paths when detecting a version. - WASM API - Added `DetectVersionOutput.ignore` field. diff --git a/Cargo.lock b/Cargo.lock index e6ec4c74b..ed2595255 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2354,6 +2354,7 @@ dependencies = [ "starbase_utils", "thiserror", "tinytemplate", + "tokio", "tracing", "url", "version_spec", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4ce9a2a57..16814f517 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -34,3 +34,4 @@ url = "2.4.1" [dev-dependencies] starbase_sandbox = { workspace = true } +tokio = { workspace = true } diff --git a/crates/core/src/user_config.rs b/crates/core/src/user_config.rs index 0eec024d0..77717be59 100644 --- a/crates/core/src/user_config.rs +++ b/crates/core/src/user_config.rs @@ -10,6 +10,14 @@ use warpgate::{HttpOptions, Id, PluginLocator}; pub const USER_CONFIG_NAME: &str = "config.toml"; +#[derive(Debug, Default, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum DetectStrategy { + #[default] + FirstAvailable, + PreferPrototools, +} + #[derive(Debug, Deserialize, PartialEq, Serialize)] #[serde(rename_all = "kebab-case")] pub enum PinType { @@ -22,6 +30,7 @@ pub enum PinType { pub struct UserConfig { pub auto_clean: bool, pub auto_install: bool, + pub detect_strategy: DetectStrategy, pub node_intercept_globals: bool, pub pin_latest: Option, pub http: HttpOptions, @@ -93,6 +102,7 @@ impl Default for UserConfig { Self { auto_clean: from_var("PROTO_AUTO_CLEAN", false), auto_install: from_var("PROTO_AUTO_INSTALL", false), + detect_strategy: DetectStrategy::default(), http: HttpOptions::default(), node_intercept_globals: from_var("PROTO_NODE_INTERCEPT_GLOBALS", true), pin_latest: None, diff --git a/crates/core/src/version_detector.rs b/crates/core/src/version_detector.rs index db493cbc7..97d41959e 100644 --- a/crates/core/src/version_detector.rs +++ b/crates/core/src/version_detector.rs @@ -1,10 +1,117 @@ use crate::error::ProtoError; use crate::tool::Tool; use crate::tools_config::ToolsConfig; +use crate::user_config::DetectStrategy; use std::{env, path::Path}; use tracing::{debug, trace}; use version_spec::*; +pub async fn detect_version_first_available( + tool: &Tool, + start_dir: &Path, + end_dir: &Path, +) -> miette::Result> { + let mut current_dir: Option<&Path> = Some(start_dir); + + while let Some(dir) = current_dir { + trace!( + tool = tool.id.as_str(), + dir = ?dir, + "Checking directory", + ); + + let config = ToolsConfig::load_from(dir)?; + + if let Some(version) = config.tools.get(&tool.id) { + debug!( + tool = tool.id.as_str(), + version = version.to_string(), + file = ?config.path, + "Detected version from .prototools file", + ); + + return Ok(Some(version.to_owned())); + } + + if let Some(version) = tool.detect_version_from(dir).await? { + debug!( + tool = tool.id.as_str(), + version = version.to_string(), + "Detected version from tool's ecosystem" + ); + + return Ok(Some(version)); + } + + if dir == end_dir { + break; + } + + current_dir = dir.parent(); + } + + Ok(None) +} + +pub async fn detect_version_prefer_prototools( + tool: &Tool, + start_dir: &Path, + end_dir: &Path, +) -> miette::Result> { + let mut config_version = None; + let mut config_path = None; + let mut ecosystem_version = None; + let mut current_dir: Option<&Path> = Some(start_dir); + + while let Some(dir) = current_dir { + trace!( + tool = tool.id.as_str(), + dir = ?dir, + "Checking directory", + ); + + if config_version.is_none() { + let mut config = ToolsConfig::load_from(dir)?; + + config_version = config.tools.remove(&tool.id); + config_path = Some(config.path); + } + + if ecosystem_version.is_none() { + ecosystem_version = tool.detect_version_from(dir).await?; + } + + if dir == end_dir { + break; + } + + current_dir = dir.parent(); + } + + if let Some(version) = config_version { + debug!( + tool = tool.id.as_str(), + version = version.to_string(), + file = ?config_path.unwrap(), + "Detected version from .prototools file", + ); + + return Ok(Some(version.to_owned())); + } + + if let Some(version) = ecosystem_version { + debug!( + tool = tool.id.as_str(), + version = version.to_string(), + "Detected version from tool's ecosystem" + ); + + return Ok(Some(version)); + } + + Ok(None) +} + pub async fn detect_version( tool: &Tool, forced_version: Option, @@ -45,46 +152,18 @@ pub async fn detect_version( // Traverse upwards and attempt to detect a local version if let Ok(working_dir) = env::current_dir() { - let mut current_dir: Option<&Path> = Some(&working_dir); - - while let Some(dir) = current_dir { - trace!( - tool = tool.id.as_str(), - dir = ?dir, - "Checking directory", - ); - - // Detect from our config file - let config = ToolsConfig::load_from(dir)?; - - if let Some(local_version) = config.tools.get(&tool.id) { - debug!( - tool = tool.id.as_str(), - version = local_version.to_string(), - file = ?config.path, - "Detected version from .prototools file", - ); - - return Ok(local_version.to_owned()); + let user_config = tool.proto.load_user_config()?; + let detected_version = match user_config.detect_strategy { + DetectStrategy::FirstAvailable => { + detect_version_first_available(tool, &working_dir, &tool.proto.home).await? } - - // Detect using the tool - if let Some(detected_version) = tool.detect_version_from(dir).await? { - debug!( - tool = tool.id.as_str(), - version = detected_version.to_string(), - "Detected version from tool's ecosystem" - ); - - return Ok(detected_version); - } - - // Don't traverse passed the home directory - if dir == tool.proto.home { - break; + DetectStrategy::PreferPrototools => { + detect_version_prefer_prototools(tool, &working_dir, &tool.proto.home).await? } + }; - current_dir = dir.parent(); + if let Some(version) = detected_version { + return Ok(version); } } diff --git a/crates/core/tests/user_config_test.rs b/crates/core/tests/user_config_test.rs index 4a1b25a85..49bea7aa5 100644 --- a/crates/core/tests/user_config_test.rs +++ b/crates/core/tests/user_config_test.rs @@ -1,4 +1,4 @@ -use proto_core::{PinType, UserConfig, USER_CONFIG_NAME}; +use proto_core::{DetectStrategy, PinType, UserConfig, USER_CONFIG_NAME}; use starbase_sandbox::create_empty_sandbox; use std::collections::BTreeMap; use std::env; @@ -17,6 +17,7 @@ mod user_config { UserConfig { auto_clean: false, auto_install: false, + detect_strategy: DetectStrategy::default(), node_intercept_globals: true, http: HttpOptions::default(), pin_latest: None, @@ -46,6 +47,7 @@ pin-latest = "global" UserConfig { auto_clean: true, auto_install: true, + detect_strategy: DetectStrategy::default(), node_intercept_globals: false, http: HttpOptions::default(), pin_latest: Some(PinType::Global), @@ -71,6 +73,7 @@ pin-latest = "global" UserConfig { auto_clean: true, auto_install: true, + detect_strategy: DetectStrategy::default(), node_intercept_globals: false, http: HttpOptions::default(), pin_latest: None, diff --git a/crates/core/tests/version_detector_test.rs b/crates/core/tests/version_detector_test.rs new file mode 100644 index 000000000..885a8304d --- /dev/null +++ b/crates/core/tests/version_detector_test.rs @@ -0,0 +1,103 @@ +use proto_core::{ + detect_version_first_available, detect_version_prefer_prototools, load_tool_from_locator, + ProtoEnvironment, Tool, ToolsConfig, UnresolvedVersionSpec, UserConfig, +}; +use starbase_sandbox::create_empty_sandbox; +use std::path::Path; +use warpgate::Id; + +mod version_detector { + use super::*; + + async fn create_node(_root: &Path) -> Tool { + load_tool_from_locator( + Id::raw("node"), + ProtoEnvironment::new().unwrap(), + ToolsConfig::builtin_plugins().get("node").unwrap(), + &UserConfig::default(), + ) + .await + .unwrap() + } + + #[tokio::test] + async fn uses_deepest_prototools() { + let sandbox = create_empty_sandbox(); + sandbox.create_file("a/.prototools", "node = \"20\""); + sandbox.create_file("a/b/.prototools", "node = \"18\""); + sandbox.create_file("a/b/c/.prototools", "node = \"16\""); + + let tool = create_node(sandbox.path()).await; + + assert_eq!( + detect_version_first_available(&tool, &sandbox.path().join("a/b/c"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~16").unwrap()) + ); + + assert_eq!( + detect_version_first_available(&tool, &sandbox.path().join("a/b"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~18").unwrap()) + ); + + assert_eq!( + detect_version_first_available(&tool, &sandbox.path().join("a"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~20").unwrap()) + ); + } + + #[tokio::test] + async fn finds_first_available_prototools() { + let sandbox = create_empty_sandbox(); + sandbox.create_file("a/.prototools", "node = \"20\""); + sandbox.create_file("package.json", r#"{ "engines": { "node": "18" } }"#); + + let tool = create_node(sandbox.path()).await; + + assert_eq!( + detect_version_first_available(&tool, &sandbox.path().join("a/b"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~20").unwrap()) + ); + } + + #[tokio::test] + async fn finds_first_available_ecosystem() { + let sandbox = create_empty_sandbox(); + sandbox.create_file(".prototools", "node = \"20\""); + sandbox.create_file("a/package.json", r#"{ "engines": { "node": "18" } }"#); + + let tool = create_node(sandbox.path()).await; + + assert_eq!( + detect_version_first_available(&tool, &sandbox.path().join("a/b"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~18").unwrap()) + ); + } + + #[tokio::test] + async fn prefers_prototools() { + let sandbox = create_empty_sandbox(); + sandbox.create_file("a/.prototools", "node = \"20\""); + sandbox.create_file("a/b/.prototools", "node = \"18\""); + sandbox.create_file("a/b/package.json", r#"{ "engines": { "node": "17" } }"#); + sandbox.create_file("a/b/c/package.json", r#"{ "engines": { "node": "19" } }"#); + + let tool = create_node(sandbox.path()).await; + + assert_eq!( + detect_version_prefer_prototools(&tool, &sandbox.path().join("a/b/c"), sandbox.path()) + .await + .unwrap(), + Some(UnresolvedVersionSpec::parse("~18").unwrap()) + ); + } +}