#![forbid(unsafe_code)]
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
UserSetting,
Env,
Path,
Bundled,
Pkgmgr,
DotnetTool,
NpmGlobal,
CargoBin,
GithubRelease,
LspInitialize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProbedVersion {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct EnvConfig {
#[serde(rename = "pathVar", skip_serializing_if = "Option::is_none")]
pub path_var: Option<String>,
#[serde(rename = "dirVar", skip_serializing_if = "Option::is_none")]
pub dir_var: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PkgmgrConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub brew: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub scoop: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub apt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub winget: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DotnetToolConfig {
pub package: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResolveInput<'a> {
pub binary_name: &'a str,
pub expected_name: Option<&'a str>,
pub expected_version: &'a str,
pub sources: &'a [Source],
pub platform: Platform,
pub user_setting_path: Option<&'a str>,
pub env: &'a HashMap<String, String>,
pub env_config: EnvConfig,
pub path_entries: &'a [String],
pub bundled_dir: Option<&'a str>,
pub cargo_bin: Option<&'a str>,
pub pkgmgr: Option<&'a PkgmgrConfig>,
pub dotnet_tool: Option<&'a DotnetToolConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Platform {
DarwinArm64,
DarwinX64,
LinuxX64,
LinuxArm64,
Win32X64,
Win32Arm64,
All,
}
impl Platform {
#[must_use]
pub fn exe_suffix(self) -> &'static str {
if matches!(self, Self::Win32X64 | Self::Win32Arm64) {
".exe"
} else {
""
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Status {
Ok,
OkWithWarning,
Deferred,
Prompt,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WarningCode {
EnvVersionMismatch,
BundledVersionDrift,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ErrorCode {
UserSettingVersionMismatch,
NoSourceResolved,
BinaryNameMismatch,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum DeferredCheck {
LspInitialize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "kind")]
pub enum PromptAction {
PkgmgrInstall {
commands: HashMap<String, String>,
},
DotnetToolUpdate {
command: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorDetails {
pub expected: String,
pub found: String,
pub at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Resolution {
pub source: Option<Source>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub status: Status,
#[serde(rename = "warningCode", skip_serializing_if = "Option::is_none")]
pub warning_code: Option<WarningCode>,
#[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")]
pub error_code: Option<ErrorCode>,
#[serde(rename = "errorDetails", skip_serializing_if = "Option::is_none")]
pub error_details: Option<ErrorDetails>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<PromptAction>,
#[serde(rename = "deferredCheck", skip_serializing_if = "Option::is_none")]
pub deferred_check: Option<DeferredCheck>,
}
impl Resolution {
#[must_use]
pub fn ok(source: Source, path: String, version: String) -> Self {
Self {
source: Some(source),
path: Some(path),
version: Some(version),
status: Status::Ok,
warning_code: None,
error_code: None,
error_details: None,
action: None,
deferred_check: None,
}
}
#[must_use]
pub fn ok_warn(source: Source, path: String, version: String, code: WarningCode) -> Self {
let mut r = Self::ok(source, path, version);
r.status = Status::OkWithWarning;
r.warning_code = Some(code);
r
}
#[must_use]
pub fn error(code: ErrorCode, details: Option<ErrorDetails>) -> Self {
Self {
source: None,
path: None,
version: None,
status: Status::Error,
warning_code: None,
error_code: Some(code),
error_details: details,
action: None,
deferred_check: None,
}
}
#[must_use]
pub fn prompt(action: PromptAction) -> Self {
Self {
source: None,
path: None,
version: None,
status: Status::Prompt,
warning_code: None,
error_code: None,
error_details: None,
action: Some(action),
deferred_check: None,
}
}
#[must_use]
pub fn deferred(source: Source, path: String, check: DeferredCheck) -> Self {
Self {
source: Some(source),
path: Some(path),
version: None,
status: Status::Deferred,
warning_code: None,
error_code: None,
error_details: None,
action: None,
deferred_check: Some(check),
}
}
}
pub fn resolve<F>(input: &ResolveInput<'_>, mut probe: F) -> Resolution
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
for source in input.sources {
if let Some(r) = try_source(*source, input, &mut probe) {
return r;
}
}
Resolution::error(ErrorCode::NoSourceResolved, None)
}
fn try_source<F>(source: Source, input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
match source {
Source::UserSetting => try_user_setting(input, probe),
Source::Env => try_env(input, probe),
Source::Path => try_path(input, probe),
Source::Bundled => try_bundled(input, probe),
Source::CargoBin => try_cargo_bin(input),
Source::Pkgmgr => try_pkgmgr(input),
Source::DotnetTool => try_dotnet_tool(input, probe),
Source::NpmGlobal | Source::GithubRelease | Source::LspInitialize => None,
}
}
fn try_user_setting<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
let path = input.user_setting_path?;
match probe(path) {
Some(got) if !name_matches(input, &got) => {
Some(Resolution::error(ErrorCode::BinaryNameMismatch, None))
}
Some(got) if got.version == input.expected_version => Some(Resolution::ok(
Source::UserSetting,
path.to_string(),
got.version,
)),
Some(got) => Some(Resolution::error(
ErrorCode::UserSettingVersionMismatch,
Some(ErrorDetails {
expected: input.expected_version.to_string(),
found: got.version,
at: path.to_string(),
}),
)),
None => Some(Resolution::error(
ErrorCode::UserSettingVersionMismatch,
Some(ErrorDetails {
expected: input.expected_version.to_string(),
found: String::new(),
at: path.to_string(),
}),
)),
}
}
fn try_env<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
let path = env_path(input)?;
let got = probe(&path)?;
if !name_matches(input, &got) {
return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
}
Some(if got.version == input.expected_version {
Resolution::ok(Source::Env, path, got.version)
} else {
Resolution::ok_warn(
Source::Env,
path,
got.version,
WarningCode::EnvVersionMismatch,
)
})
}
fn try_path<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
for entry in input.path_entries {
let candidate = join_binary(entry, input.binary_name, input.platform);
if let Some(got) = probe(&candidate) {
if !name_matches(input, &got) {
return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
}
if got.version == input.expected_version {
return Some(Resolution::ok(Source::Path, candidate, got.version));
}
}
}
None
}
fn try_bundled<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
let dir = input.bundled_dir?;
let candidate = join_binary(dir, input.binary_name, input.platform);
let got = probe(&candidate)?;
if !name_matches(input, &got) {
return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
}
Some(if got.version == input.expected_version {
Resolution::ok(Source::Bundled, candidate, got.version)
} else {
Resolution::ok_warn(
Source::Bundled,
candidate,
got.version,
WarningCode::BundledVersionDrift,
)
})
}
fn try_cargo_bin(input: &ResolveInput<'_>) -> Option<Resolution> {
input.cargo_bin.map(|p| {
Resolution::deferred(
Source::CargoBin,
p.to_string(),
DeferredCheck::LspInitialize,
)
})
}
fn try_pkgmgr(input: &ResolveInput<'_>) -> Option<Resolution> {
input.pkgmgr.map(|p| {
Resolution::prompt(PromptAction::PkgmgrInstall {
commands: pkgmgr_commands(p),
})
})
}
fn try_dotnet_tool<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
where
F: FnMut(&str) -> Option<ProbedVersion>,
{
let dt = input.dotnet_tool?;
let cmd = dt.command.as_deref().unwrap_or(&dt.package);
Some(match probe(cmd) {
Some(got) if got.version == input.expected_version => {
Resolution::ok(Source::DotnetTool, cmd.to_string(), got.version)
}
Some(_) => Resolution::prompt(PromptAction::DotnetToolUpdate {
command: format!(
"dotnet tool update -g {} --version {}",
dt.package, input.expected_version
),
}),
None => Resolution::prompt(PromptAction::DotnetToolUpdate {
command: format!(
"dotnet tool install -g {} --version {}",
dt.package, input.expected_version
),
}),
})
}
fn name_matches(input: &ResolveInput<'_>, probed: &ProbedVersion) -> bool {
match input.expected_name {
Some(name) => probed.name == name,
None => probed.name == input.binary_name,
}
}
fn env_path(input: &ResolveInput<'_>) -> Option<String> {
if let Some(var) = input.env_config.path_var.as_deref() {
if let Some(v) = input.env.get(var) {
return Some(v.clone());
}
}
if let Some(var) = input.env_config.dir_var.as_deref() {
if let Some(dir) = input.env.get(var) {
return Some(join_binary(dir, input.binary_name, input.platform));
}
}
None
}
fn join_binary(dir: &str, name: &str, platform: Platform) -> String {
let trimmed = dir.trim_end_matches(['/', '\\']);
format!("{trimmed}/{name}{}", platform.exe_suffix())
}
fn pkgmgr_commands(pkg: &PkgmgrConfig) -> HashMap<String, String> {
let mut map = HashMap::new();
if let Some(b) = pkg.brew.as_deref() {
for p in ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] {
let _ = map.insert(p.to_string(), format!("brew install {b}"));
}
}
if let Some(s) = pkg.scoop.as_deref() {
let _ = map.insert("win32-x64".to_string(), format!("scoop install {s}"));
let _ = map.insert("win32-arm64".to_string(), format!("scoop install {s}"));
}
map
}