diff --git a/examples/devnet/trix.toml b/examples/devnet/trix.toml index cb996e2..f58b78b 100644 --- a/examples/devnet/trix.toml +++ b/examples/devnet/trix.toml @@ -29,7 +29,7 @@ output_dir = "./gen/typescript" [bindings.template] repo = "tx3-lang/web-sdk" path = ".trix/client-lib" -ref = "bindgen-v1alpha2" +ref = "codegen-v1beta0" [[wallets]] name = "alice" diff --git a/examples/test/trix.toml b/examples/test/trix.toml index 1605daf..bf995d8 100644 --- a/examples/test/trix.toml +++ b/examples/test/trix.toml @@ -14,7 +14,7 @@ output_dir = "./gen/typescript" [bindings.template] repo = "tx3-lang/web-sdk" path = ".trix/client-lib" -ref = "bindgen-v1alpha2" +ref = "codegen-v1beta0" [profiles.mainnet] env_file = ".env.mainnet" diff --git a/src/commands/codegen.rs b/src/commands/codegen.rs index c429599..9386002 100644 --- a/src/commands/codegen.rs +++ b/src/commands/codegen.rs @@ -1,188 +1,32 @@ -use std::io::Read; -use std::{collections::HashMap, path::PathBuf}; +use std::path::PathBuf; use crate::config::{CodegenPluginConfig, ProfileConfig, RootConfig}; use clap::Args as ClapArgs; use miette::IntoDiagnostic; -use serde::{Serialize, Serializer}; - -use convert_case::{Case, Casing}; -use handlebars::{Context, Handlebars, Helper, Output, RenderContext, RenderErrorReason}; use reqwest::Client; use tempfile::TempDir; -use tx3_lang::Workspace; use zip::ZipArchive; -use tx3_tir::model::core::Type as TirType; - #[derive(ClapArgs, Debug)] pub struct Args {} -/// Structure returned by load_github_templates containing handlebars and optional config -struct TemplateBundle { - handlebars: Handlebars<'static>, - static_files: Vec<(String, String)>, -} - -fn make_helper(name: &'static str, f: F) -> impl handlebars::HelperDef + Send + Sync + 'static -where - F: Fn(&str) -> String + Send + Sync + 'static, -{ - move |h: &Helper, _: &Handlebars, _: &Context, _: &mut RenderContext, out: &mut dyn Output| { - let param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex(name, 0))?; - let input = param - .value() - .as_str() - .ok_or_else(|| RenderErrorReason::InvalidParamType("Expected a string"))?; - out.write(&f(input))?; - Ok(()) - } -} - -fn parse_type_from_string(type_str: &str) -> Result { - match type_str { - "Int" => Ok(TirType::Int), - "Bool" => Ok(TirType::Bool), - "Bytes" => Ok(TirType::Bytes), - "Unit" => Ok(TirType::Unit), - "Address" => Ok(TirType::Address), - "UtxoRef" => Ok(TirType::UtxoRef), - "AnyAsset" => Ok(TirType::AnyAsset), - "Utxo" => Ok(TirType::Utxo), - "Undefined" => Ok(TirType::Undefined), - "List" => Ok(TirType::List), - x => Ok(TirType::Custom(x.to_string())), - } -} - -fn get_type_for_language(type_: &TirType, language: &str) -> String { - match language { - "rust" => "ArgValue".to_string(), - "typescript" => match &type_ { - TirType::Int => "bigint | number".to_string(), - TirType::Bool => "boolean".to_string(), - TirType::Bytes => "Uint8Array".to_string(), - TirType::Unit => "void".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "any[]".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "any".to_string(), - TirType::Undefined => "any".to_string(), - TirType::Map => "any".to_string(), - }, - "python" => match &type_ { - TirType::Int => "int".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "bytes".to_string(), - TirType::Unit => "None".to_string(), - TirType::List => "list[Any]".to_string(), - TirType::Address => "str".to_string(), - TirType::UtxoRef => "str".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "str".to_string(), - TirType::Undefined => "Any".to_string(), - TirType::Utxo => "Any".to_string(), - TirType::Map => "Any".to_string(), - }, - "go" => match &type_ { - TirType::Int => "int64".to_string(), - TirType::Bool => "bool".to_string(), - TirType::Bytes => "[]byte".to_string(), - TirType::Unit => "struct{}".to_string(), - TirType::Address => "string".to_string(), - TirType::UtxoRef => "string".to_string(), - TirType::List => "[]interface{}".to_string(), - TirType::Custom(name) => name.clone(), - TirType::AnyAsset => "string".to_string(), - TirType::Utxo => "interface{}".to_string(), - TirType::Undefined => "interface{}".to_string(), - TirType::Map => "interface{}".to_string(), - }, - _ => "ArgValue".to_string(), // Default fallback - } -} - -// Register any custom helpers here -/// An array of helper functions for converting strings to various case styles. -/// -/// Each tuple in the array consists of: -/// - A string slice representing the name of the case style (e.g., "pascalCase"). -/// - A function pointer that takes a string slice and returns a `String` converted to the corresponding case style. -/// -/// These helpers are useful for dynamically applying different case transformations to strings, -/// such as converting identifiers to PascalCase, camelCase, CONSTANT_CASE, snake_case, or lower case. -fn register_handlebars_helpers(handlebars: &mut Handlebars<'_>) { - #[allow(clippy::type_complexity)] - let helpers: &[(&str, fn(&str) -> String)] = &[ - ("pascalCase", |s| s.to_case(Case::Pascal)), - ("camelCase", |s| s.to_case(Case::Camel)), - ("constantCase", |s| s.to_case(Case::Constant)), - ("snakeCase", |s| s.to_case(Case::Snake)), - ("lowerCase", |s| s.to_case(Case::Lower)), - ]; - - for (name, func) in helpers { - handlebars.register_helper(name, Box::new(make_helper(name, func))); - } - // Add more helpers as needed - - // Register helper to convert ir types to language types. - handlebars.register_helper( - "typeFor", - Box::new( - |h: &Helper, - _: &Handlebars, - _: &Context, - _: &mut RenderContext, - out: &mut dyn Output| { - let type_param = h - .param(0) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 0))?; - let lang_param = h - .param(1) - .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("typeFor", 1))?; - - let type_str = type_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected type as string") - })?; - - let type_ = parse_type_from_string(type_str) - .map_err(|_| RenderErrorReason::InvalidParamType("Failed to parse type"))?; - - let language = lang_param.value().as_str().ok_or_else(|| { - RenderErrorReason::InvalidParamType("Expected language as string") - })?; - - let output_type = get_type_for_language(&type_, language); - - out.write(&output_type)?; - Ok(()) - }, - ), - ); -} - -/// Loads Handlebars templates from a GitHub repository ZIP archive. -/// -/// This function: -/// 1. Parses the GitHub URL in the format 'owner/repo' or 'owner/repo/branch' -/// 2. Downloads the repository as a ZIP file from GitHub -/// 3. Extracts the ZIP to a temporary directory -/// 4. Finds all `.hbs` files inside any `bindgen` directory in the archive -/// 5. Optionally loads a `trix-bindgen.toml` file from the `bindgen` directory -/// 6. Registers each found template with Handlebars, using its path relative to `bindgen/` (without the `.hbs` extension) -/// -/// Returns a TemplateBundle containing the Handlebars registry and optional configuration. -async fn load_github_templates( +async fn extract_github_templates( github_url: &str, temp_dir: &TempDir, path: &str, -) -> miette::Result { - // Parse GitHub URL +) -> miette::Result { + let local_root = PathBuf::from(github_url); + if local_root.is_dir() { + let template_root = local_root.join(path); + if !template_root.is_dir() { + return Err(miette::miette!( + "Template path '{}' does not exist", + template_root.display() + )); + } + return Ok(template_root); + } + let parts: Vec<&str> = github_url.split('/').collect(); if parts.len() < 2 { return Err(miette::miette!( @@ -194,7 +38,6 @@ async fn load_github_templates( let repo = parts[1]; let branch = if parts.len() > 2 { parts[2] } else { "main" }; - // Create a zip download URL let zip_url = format!( "https://github.com/{}/{}/archive/{}.zip", owner, repo, branch @@ -205,7 +48,6 @@ async fn load_github_templates( owner, repo, branch ); - // Download the zip file let client = Client::new(); let response = client.get(&zip_url).send().await.into_diagnostic()?; @@ -217,288 +59,71 @@ async fn load_github_templates( } let zip_path = temp_dir.path().join("bindgen-template.zip"); - - // Save the zip file let content = response.bytes().await.into_diagnostic()?; std::fs::write(&zip_path, &content).into_diagnostic()?; - // Extract the zip file let file = std::fs::File::open(&zip_path).into_diagnostic()?; let mut archive = ZipArchive::new(file).into_diagnostic()?; let mut bindgen_path = PathBuf::new(); - - // Get root_dir let root_dir_name = archive.name_for_index(0).unwrap_or(""); - bindgen_path.push(root_dir_name); bindgen_path.push(path); - // Ensure the bindgen path ends with a separator bindgen_path.push(""); - // let mut config: Option = None; - // Check for trix-bindgen.toml in the directory - // let toml_name = bindgen_path.join("trix-bindgen.toml").to_string_lossy().to_string(); - - // if let Ok(mut config_file) = archive.by_name(&toml_name) { - // let mut config_content = String::new(); - // config_file.read_to_string(&mut config_content).into_diagnostic()?; - - // config = toml::from_str::(&config_content) - // .into_diagnostic() - // .ok(); - // } - - // Register handlebars templates - let mut handlebars = Handlebars::new(); - let mut static_files = Vec::new(); - let bindgen_path_string = bindgen_path.to_string_lossy().to_string(); let archive_bindgen_index = archive.index_for_name(&bindgen_path_string).unwrap_or(0); - // Skip files that are not in the bindgen_path or are the bindgen_path itself + let template_root = temp_dir.path().join("templates"); + std::fs::create_dir_all(&template_root).into_diagnostic()?; + for i in archive_bindgen_index..archive.len() { let mut file = archive.by_index(i).into_diagnostic()?; let name = file.name().to_owned(); if !name.starts_with(&bindgen_path_string) { - break; // Stop processing if we reach a file outside the bindgen path + break; } - // If the file is a directory or its the trix-bindgen.toml, skip it if file.is_dir() || name.ends_with("trix-bindgen.toml") { continue; } - // Remove everything before "bindgen/" and strip ".hbs" extension - let template_name = name.strip_prefix(&bindgen_path_string).unwrap_or(&name); - - if name.ends_with(".hbs") { - let template_name = template_name.strip_suffix(".hbs").unwrap_or(&name); - - let mut template_content = String::new(); - file.read_to_string(&mut template_content) - .into_diagnostic()?; - - // Register handlebars template - handlebars - .register_template_string(template_name, template_content) - .into_diagnostic()?; - - // println!("Registered template: {}", template_name); - continue; - } - - if file.is_file() { - let dest_path = temp_dir.path().join(template_name); - if let Some(parent) = dest_path.parent() { - std::fs::create_dir_all(parent).into_diagnostic()?; - } - let mut out_file = std::fs::File::create(&dest_path).into_diagnostic()?; - std::io::copy(&mut file, &mut out_file).into_diagnostic()?; - static_files.push((dest_path.display().to_string(), template_name.to_string())); - } - } - - register_handlebars_helpers(&mut handlebars); - - Ok(TemplateBundle { - handlebars, - static_files, - }) -} - -struct BytesHex(Vec); - -impl Serialize for BytesHex { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer, - { - serializer.serialize_str(&hex::encode(&self.0)) - } -} - -#[derive(Serialize)] -struct TxParameter { - name: String, - type_name: tx3_tir::model::core::Type, -} - -#[derive(Serialize)] -struct Transaction { - name: String, - params_name: String, - function_name: String, - constant_name: String, - ir_bytes: BytesHex, - ir_version: String, - parameters: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct HandlebarsData { - protocol_name: String, - protocol_version: String, - trp_endpoint: String, - transactions: Vec, - headers: HashMap, - env_vars: HashMap, - options: HashMap, -} - -struct Job<'a> { - name: String, - workspace: &'a Workspace, - dest_path: PathBuf, - trp_endpoint: String, - trp_headers: HashMap, - env_args: HashMap, - options: HashMap, -} - -fn generate_arguments(job: &Job, version: &str) -> miette::Result { - let ast = job - .workspace - .ast() - .ok_or(miette::miette!("No AST available in workspace"))?; - - let transactions = ast - .txs - .iter() - .map(|tx_def| { - let tx_name = tx_def.name.value.as_str(); - let tx_tir = job.workspace.tir(tx_def.name.value.as_str()).unwrap(); - - let parameters: Vec = tx3_tir::reduce::find_params(tx_tir) - .iter() - .map(|(key, type_)| TxParameter { - name: key.as_str().to_case(Case::Camel), - type_name: type_.clone(), - }) - .collect(); - - let (tx_bytes, version) = tx3_tir::encoding::to_bytes(tx_tir); - - Transaction { - name: tx_name.to_string(), - params_name: format!("{}Params", tx_name).to_case(Case::Camel), - function_name: format!("{}Tx", tx_name).to_case(Case::Camel), - constant_name: format!("{}Ir", tx_name).to_case(Case::Camel), - ir_bytes: BytesHex(tx_bytes), - ir_version: version.to_string(), - parameters, - } - }) - .collect(); - - let headers = job - .trp_headers - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - let env_vars = job - .env_args - .iter() - .map(|(key, value)| (key.clone(), value.clone())) - .collect::>(); - - Ok(HandlebarsData { - protocol_name: job.name.clone(), - protocol_version: version.to_string(), - trp_endpoint: job.trp_endpoint.clone(), - transactions, - headers, - env_vars, - options: job.options.clone(), - }) -} - -async fn execute_bindgen( - job: &Job<'_>, - template_config: &CodegenPluginConfig, - version: &str, -) -> miette::Result<()> { - // Create a temporary directory to extract files - let temp_dir = TempDir::new().into_diagnostic()?; - let github_url = format!( - "{}/{}", - &template_config.repo, - template_config.r#ref.as_deref().unwrap_or("main") - ); - - let template_bundle = - load_github_templates(&github_url, &temp_dir, &template_config.path).await?; - - // Create the destination directory if it doesn't exist - std::fs::create_dir_all(&job.dest_path).into_diagnostic()?; - - let handlebars_params = generate_arguments(job, version)?; - - let handlebars_template_iter = template_bundle.handlebars.get_templates().iter(); - - for (name, _) in handlebars_template_iter { - let template_content = template_bundle - .handlebars - .render(name, &handlebars_params) - .unwrap(); - if template_content.is_empty() { - // Skip empty templates - continue; - } - let output_path = job.dest_path.join(name); - if let Some(parent) = output_path.parent() { - // Create parent directories if they don't exist - std::fs::create_dir_all(parent).into_diagnostic()?; - } - std::fs::write(&output_path, template_content).into_diagnostic()?; - // println!("Generated file: {}", output_path.display()); - } - - // Copy static files to the destination directory - for (src_path, file_destination) in &template_bundle.static_files { - let dest_path = job.dest_path.join(file_destination); + let relative = name.strip_prefix(&bindgen_path_string).unwrap_or(&name); + let dest_path = template_root.join(relative); if let Some(parent) = dest_path.parent() { std::fs::create_dir_all(parent).into_diagnostic()?; } - std::fs::copy(src_path, dest_path).into_diagnostic()?; - // println!("Copied static file: {}", dest_path.display()); + + let mut out_file = std::fs::File::create(&dest_path).into_diagnostic()?; + std::io::copy(&mut file, &mut out_file).into_diagnostic()?; } - Ok(()) + Ok(template_root) } -pub async fn run(_args: Args, config: &RootConfig, profile: &ProfileConfig) -> miette::Result<()> { - let mut ws = Workspace::from_file(&config.protocol.main)?; - - ws.parse()?; - ws.analyze()?; - ws.lower()?; +pub async fn run(_args: Args, config: &RootConfig, _profile: &ProfileConfig) -> miette::Result<()> { + let tii_path = crate::builder::build_tii(config)?; for codegen in config.codegen.iter() { let output_dir = codegen.output_dir()?; - std::fs::create_dir_all(&output_dir).into_diagnostic()?; let plugin = CodegenPluginConfig::from(codegen.plugin.clone()); - - let network = config.resolve_profile_network(profile.name.as_str())?; - - let trp_config = &network.trp; - - let job = Job { - name: config.protocol.name.clone(), - workspace: &ws, - dest_path: output_dir, - trp_endpoint: trp_config.url.clone(), - trp_headers: trp_config.headers.clone(), - env_args: HashMap::new(), - options: codegen.options.clone().unwrap_or_default(), + let github_url = if PathBuf::from(&plugin.repo).is_dir() { + plugin.repo.clone() + } else { + format!( + "{}/{}", + &plugin.repo, + plugin.r#ref.as_deref().unwrap_or("main") + ) }; - execute_bindgen(&job, &plugin, &config.protocol.version).await?; + let template_temp = TempDir::new().into_diagnostic()?; + let templates_dir = extract_github_templates(&github_url, &template_temp, &plugin.path).await?; + + crate::spawn::tx3c::codegen(&tii_path, &templates_dir, &output_dir)?; println!("Bindgen successful"); } diff --git a/src/config/convention.rs b/src/config/convention.rs index 08b9357..d5524e9 100644 --- a/src/config/convention.rs +++ b/src/config/convention.rs @@ -222,22 +222,22 @@ impl From for CodegenPluginConfig { repo: "tx3-lang/web-sdk".to_string(), // When web-sdk get updated, we need to change this path to bindgen/client-lib when we update the ref path: ".trix/client-lib".to_string(), - r#ref: Some("bindgen-v1alpha2".to_string()), + r#ref: Some("codegen-v1beta0".to_string()), }, KnownCodegenPlugin::RustClient => CodegenPluginConfig { repo: "tx3-lang/rust-sdk".to_string(), path: ".trix/client-lib".to_string(), - r#ref: Some("bindgen-v1alpha2".to_string()), + r#ref: Some("codegen-v1beta0".to_string()), }, KnownCodegenPlugin::PythonClient => CodegenPluginConfig { repo: "tx3-lang/python-sdk".to_string(), path: ".trix/client-lib".to_string(), - r#ref: Some("bindgen-v1alpha2".to_string()), + r#ref: Some("codegen-v1beta0".to_string()), }, KnownCodegenPlugin::GoClient => CodegenPluginConfig { repo: "tx3-lang/go-sdk".to_string(), path: ".trix/client-lib".to_string(), - r#ref: Some("bindgen-v1alpha2".to_string()), + r#ref: Some("codegen-v1beta0".to_string()), }, } } diff --git a/src/spawn/tx3c.rs b/src/spawn/tx3c.rs index eb7c079..4c895ad 100644 --- a/src/spawn/tx3c.rs +++ b/src/spawn/tx3c.rs @@ -43,3 +43,24 @@ pub fn build_tii(source: &Path, output: &Path, config: &RootConfig) -> miette::R Ok(()) } + +pub fn codegen(tii_path: &Path, templates: &Path, output: &Path) -> miette::Result<()> { + let tool_path = crate::home::tool_path("tx3c")?; + + let mut cmd = Command::new(tool_path.to_str().unwrap_or_default()); + + cmd.args(["codegen", "--tii", tii_path.to_str().unwrap()]); + cmd.args(["--template", templates.to_str().unwrap()]); + cmd.args(["--output", output.to_str().unwrap()]); + + let output = cmd + .status() + .into_diagnostic() + .context("running tx3c codegen")?; + + if !output.success() { + bail!("tx3c failed to run codegen"); + } + + Ok(()) +} diff --git a/tests/e2e/fixtures/codegen-template/bindings.txt.hbs b/tests/e2e/fixtures/codegen-template/bindings.txt.hbs new file mode 100644 index 0000000..f223a5b --- /dev/null +++ b/tests/e2e/fixtures/codegen-template/bindings.txt.hbs @@ -0,0 +1,9 @@ +Protocol: {{tii.protocol.name}} {{tii.protocol.version}} +Transactions: +{{#each tii.transactions}} +- {{@key}} +{{/each}} +Profiles: +{{#each tii.profiles}} +- {{@key}} +{{/each}} diff --git a/tests/e2e/happy_path.rs b/tests/e2e/happy_path.rs index 812a838..1c593ce 100644 --- a/tests/e2e/happy_path.rs +++ b/tests/e2e/happy_path.rs @@ -144,3 +144,39 @@ fn devnet_starts_and_cshell_connects() { .args(["-f", "dolos"]) .output(); } + +#[test] +fn codegen_generates_bindings_from_fixture() { + let ctx = TestContext::new(); + + let init_result = ctx.run_trix(&["init", "--yes"]); + assert_success(&init_result); + + let tx3c_path = ctx + .tx3c_path() + .expect("tx3c should be available in PATH or TX3_TX3C_PATH"); + assert!(tx3c_path.is_file(), "tx3c path should exist"); + + let fixture_dir = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/e2e/fixtures/codegen-template"); + let fixture_dir = fixture_dir + .to_str() + .expect("fixture path should be valid UTF-8"); + + let mut trix_toml = ctx.read_file("trix.toml"); + trix_toml.push_str(&format!( + "\n[[codegen]]\noutput_dir = \"gen\"\nplugin = {{ repo = \"{}\", path = \".\" }}\n", + fixture_dir + )); + ctx.write_file("trix.toml", &trix_toml); + + let result = ctx.run_trix(&["codegen"]); + assert_success(&result); + + ctx.assert_file_exists("gen/bindings.txt"); + ctx.assert_file_contains("gen/bindings.txt", "Protocol:"); + ctx.assert_file_contains("gen/bindings.txt", "Transactions:"); + ctx.assert_file_contains("gen/bindings.txt", "transfer"); + ctx.assert_file_contains("gen/bindings.txt", "Profiles:"); + ctx.assert_file_contains("gen/bindings.txt", "local"); +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs index f313d12..58e10cc 100644 --- a/tests/e2e/mod.rs +++ b/tests/e2e/mod.rs @@ -29,6 +29,33 @@ impl TestContext { cmd.args(args); cmd.current_dir(self.path()); + for (key, value) in self.tool_envs() { + cmd.env(key, value); + } + + let output = cmd.output().expect("Failed to execute trix command"); + + CommandResult { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + status: output.status, + } + } + + /// Run trix command with environment overrides + pub fn run_trix_with_env(&self, args: &[&str], envs: &[(&str, &str)]) -> CommandResult { + let mut cmd = Command::cargo_bin("trix").expect("Failed to find trix binary"); + cmd.args(args); + cmd.current_dir(self.path()); + + for (key, value) in self.tool_envs() { + cmd.env(key, value); + } + + for (key, value) in envs { + cmd.env(key, value); + } + let output = cmd.output().expect("Failed to execute trix command"); CommandResult { @@ -38,6 +65,23 @@ impl TestContext { } } + pub fn tx3c_path(&self) -> Option { + resolve_tool_path("tx3c") + } + + fn tool_envs(&self) -> Vec<(String, String)> { + let mut envs = Vec::new(); + + if let Some(path) = resolve_tool_path("tx3c") { + envs.push(( + "TX3_TX3C_PATH".to_string(), + path.to_string_lossy().to_string(), + )); + } + + envs + } + /// Get full path to a file in the temp directory pub fn file_path(&self, path: impl AsRef) -> PathBuf { self.path().join(path) @@ -165,3 +209,41 @@ pub fn is_process_running(_pid: u32) -> bool { pub mod edge_cases; pub mod happy_path; pub mod smoke; + +fn resolve_tool_path(tool: &str) -> Option { + let env_var = format!("TX3_{}_PATH", tool.to_uppercase()); + if let Ok(path) = std::env::var(&env_var) { + let path = PathBuf::from(path); + if path.is_file() { + return Some(path); + } + } + + let cargo_bin_var = format!("CARGO_BIN_EXE_{tool}"); + if let Ok(path) = std::env::var(&cargo_bin_var) { + let path = PathBuf::from(path); + if path.is_file() { + return Some(path); + } + } + + let cargo_home = std::env::var("CARGO_HOME") + .map(PathBuf::from) + .or_else(|_| { + std::env::var("HOME") + .map(PathBuf::from) + .map(|home| home.join(".cargo")) + }) + .ok()?; + + let mut path = cargo_home.join("bin").join(tool); + if cfg!(target_os = "windows") { + path.set_extension("exe"); + } + + if path.is_file() { + return Some(path); + } + + None +}