1#![forbid(unsafe_code)]
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Source {
23 UserSetting,
25 Env,
27 Path,
29 Bundled,
31 Pkgmgr,
33 DotnetTool,
35 NpmGlobal,
37 CargoBin,
39 GithubRelease,
41 LspInitialize,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ProbedVersion {
48 pub name: String,
50 pub version: String,
52}
53
54#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct EnvConfig {
57 #[serde(rename = "pathVar", skip_serializing_if = "Option::is_none")]
59 pub path_var: Option<String>,
60 #[serde(rename = "dirVar", skip_serializing_if = "Option::is_none")]
62 pub dir_var: Option<String>,
63}
64
65#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct PkgmgrConfig {
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub brew: Option<String>,
71 #[serde(skip_serializing_if = "Option::is_none")]
73 pub scoop: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub apt: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub winget: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DotnetToolConfig {
85 pub package: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub command: Option<String>,
90}
91
92#[derive(Debug, Clone)]
94pub struct ResolveInput<'a> {
95 pub binary_name: &'a str,
97 pub expected_name: Option<&'a str>,
99 pub expected_version: &'a str,
101 pub sources: &'a [Source],
103 pub platform: Platform,
105
106 pub user_setting_path: Option<&'a str>,
108 pub env: &'a HashMap<String, String>,
110 pub env_config: EnvConfig,
112 pub path_entries: &'a [String],
114 pub bundled_dir: Option<&'a str>,
116 pub cargo_bin: Option<&'a str>,
118
119 pub pkgmgr: Option<&'a PkgmgrConfig>,
121 pub dotnet_tool: Option<&'a DotnetToolConfig>,
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "kebab-case")]
128pub enum Platform {
129 DarwinArm64,
131 DarwinX64,
133 LinuxX64,
135 LinuxArm64,
137 Win32X64,
139 Win32Arm64,
141 All,
143}
144
145impl Platform {
146 #[must_use]
148 pub fn exe_suffix(self) -> &'static str {
149 if matches!(self, Self::Win32X64 | Self::Win32Arm64) {
150 ".exe"
151 } else {
152 ""
153 }
154 }
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160pub enum Status {
161 Ok,
163 OkWithWarning,
165 Deferred,
167 Prompt,
169 Error,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "kebab-case")]
176pub enum WarningCode {
177 EnvVersionMismatch,
179 BundledVersionDrift,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum ErrorCode {
187 UserSettingVersionMismatch,
189 NoSourceResolved,
191 BinaryNameMismatch,
193}
194
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "kebab-case")]
198pub enum DeferredCheck {
199 LspInitialize,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case", tag = "kind")]
206pub enum PromptAction {
207 PkgmgrInstall {
209 commands: HashMap<String, String>,
211 },
212 DotnetToolUpdate {
214 command: String,
216 },
217}
218
219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ErrorDetails {
222 pub expected: String,
224 pub found: String,
226 pub at: String,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct Resolution {
233 pub source: Option<Source>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub path: Option<String>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 pub version: Option<String>,
241 pub status: Status,
243 #[serde(rename = "warningCode", skip_serializing_if = "Option::is_none")]
245 pub warning_code: Option<WarningCode>,
246 #[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")]
248 pub error_code: Option<ErrorCode>,
249 #[serde(rename = "errorDetails", skip_serializing_if = "Option::is_none")]
251 pub error_details: Option<ErrorDetails>,
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub action: Option<PromptAction>,
255 #[serde(rename = "deferredCheck", skip_serializing_if = "Option::is_none")]
257 pub deferred_check: Option<DeferredCheck>,
258}
259
260impl Resolution {
261 #[must_use]
263 pub fn ok(source: Source, path: String, version: String) -> Self {
264 Self {
265 source: Some(source),
266 path: Some(path),
267 version: Some(version),
268 status: Status::Ok,
269 warning_code: None,
270 error_code: None,
271 error_details: None,
272 action: None,
273 deferred_check: None,
274 }
275 }
276 #[must_use]
278 pub fn ok_warn(source: Source, path: String, version: String, code: WarningCode) -> Self {
279 let mut r = Self::ok(source, path, version);
280 r.status = Status::OkWithWarning;
281 r.warning_code = Some(code);
282 r
283 }
284 #[must_use]
286 pub fn error(code: ErrorCode, details: Option<ErrorDetails>) -> Self {
287 Self {
288 source: None,
289 path: None,
290 version: None,
291 status: Status::Error,
292 warning_code: None,
293 error_code: Some(code),
294 error_details: details,
295 action: None,
296 deferred_check: None,
297 }
298 }
299 #[must_use]
301 pub fn prompt(action: PromptAction) -> Self {
302 Self {
303 source: None,
304 path: None,
305 version: None,
306 status: Status::Prompt,
307 warning_code: None,
308 error_code: None,
309 error_details: None,
310 action: Some(action),
311 deferred_check: None,
312 }
313 }
314 #[must_use]
316 pub fn deferred(source: Source, path: String, check: DeferredCheck) -> Self {
317 Self {
318 source: Some(source),
319 path: Some(path),
320 version: None,
321 status: Status::Deferred,
322 warning_code: None,
323 error_code: None,
324 error_details: None,
325 action: None,
326 deferred_check: Some(check),
327 }
328 }
329}
330
331pub fn resolve<F>(input: &ResolveInput<'_>, mut probe: F) -> Resolution
334where
335 F: FnMut(&str) -> Option<ProbedVersion>,
336{
337 for source in input.sources {
338 if let Some(r) = try_source(*source, input, &mut probe) {
339 return r;
340 }
341 }
342 Resolution::error(ErrorCode::NoSourceResolved, None)
343}
344
345fn try_source<F>(source: Source, input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
348where
349 F: FnMut(&str) -> Option<ProbedVersion>,
350{
351 match source {
352 Source::UserSetting => try_user_setting(input, probe),
353 Source::Env => try_env(input, probe),
354 Source::Path => try_path(input, probe),
355 Source::Bundled => try_bundled(input, probe),
356 Source::CargoBin => try_cargo_bin(input),
357 Source::Pkgmgr => try_pkgmgr(input),
358 Source::DotnetTool => try_dotnet_tool(input, probe),
359 Source::NpmGlobal | Source::GithubRelease | Source::LspInitialize => None,
360 }
361}
362
363fn try_user_setting<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
365where
366 F: FnMut(&str) -> Option<ProbedVersion>,
367{
368 let path = input.user_setting_path?;
369 match probe(path) {
370 Some(got) if !name_matches(input, &got) => {
371 Some(Resolution::error(ErrorCode::BinaryNameMismatch, None))
372 }
373 Some(got) if got.version == input.expected_version => Some(Resolution::ok(
374 Source::UserSetting,
375 path.to_string(),
376 got.version,
377 )),
378 Some(got) => Some(Resolution::error(
379 ErrorCode::UserSettingVersionMismatch,
380 Some(ErrorDetails {
381 expected: input.expected_version.to_string(),
382 found: got.version,
383 at: path.to_string(),
384 }),
385 )),
386 None => Some(Resolution::error(
387 ErrorCode::UserSettingVersionMismatch,
388 Some(ErrorDetails {
389 expected: input.expected_version.to_string(),
390 found: String::new(),
391 at: path.to_string(),
392 }),
393 )),
394 }
395}
396
397fn try_env<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
399where
400 F: FnMut(&str) -> Option<ProbedVersion>,
401{
402 let path = env_path(input)?;
403 let got = probe(&path)?;
404 if !name_matches(input, &got) {
405 return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
406 }
407 Some(if got.version == input.expected_version {
408 Resolution::ok(Source::Env, path, got.version)
409 } else {
410 Resolution::ok_warn(
411 Source::Env,
412 path,
413 got.version,
414 WarningCode::EnvVersionMismatch,
415 )
416 })
417}
418
419fn try_path<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
421where
422 F: FnMut(&str) -> Option<ProbedVersion>,
423{
424 for entry in input.path_entries {
425 let candidate = join_binary(entry, input.binary_name, input.platform);
426 if let Some(got) = probe(&candidate) {
427 if !name_matches(input, &got) {
428 return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
429 }
430 if got.version == input.expected_version {
431 return Some(Resolution::ok(Source::Path, candidate, got.version));
432 }
433 }
434 }
435 None
436}
437
438fn try_bundled<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
440where
441 F: FnMut(&str) -> Option<ProbedVersion>,
442{
443 let dir = input.bundled_dir?;
444 let candidate = join_binary(dir, input.binary_name, input.platform);
445 let got = probe(&candidate)?;
446 if !name_matches(input, &got) {
447 return Some(Resolution::error(ErrorCode::BinaryNameMismatch, None));
448 }
449 Some(if got.version == input.expected_version {
450 Resolution::ok(Source::Bundled, candidate, got.version)
451 } else {
452 Resolution::ok_warn(
453 Source::Bundled,
454 candidate,
455 got.version,
456 WarningCode::BundledVersionDrift,
457 )
458 })
459}
460
461fn try_cargo_bin(input: &ResolveInput<'_>) -> Option<Resolution> {
464 input.cargo_bin.map(|p| {
465 Resolution::deferred(
466 Source::CargoBin,
467 p.to_string(),
468 DeferredCheck::LspInitialize,
469 )
470 })
471}
472
473fn try_pkgmgr(input: &ResolveInput<'_>) -> Option<Resolution> {
475 input.pkgmgr.map(|p| {
476 Resolution::prompt(PromptAction::PkgmgrInstall {
477 commands: pkgmgr_commands(p),
478 })
479 })
480}
481
482fn try_dotnet_tool<F>(input: &ResolveInput<'_>, probe: &mut F) -> Option<Resolution>
484where
485 F: FnMut(&str) -> Option<ProbedVersion>,
486{
487 let dt = input.dotnet_tool?;
488 let cmd = dt.command.as_deref().unwrap_or(&dt.package);
489 Some(match probe(cmd) {
490 Some(got) if got.version == input.expected_version => {
491 Resolution::ok(Source::DotnetTool, cmd.to_string(), got.version)
492 }
493 Some(_) => Resolution::prompt(PromptAction::DotnetToolUpdate {
494 command: format!(
495 "dotnet tool update -g {} --version {}",
496 dt.package, input.expected_version
497 ),
498 }),
499 None => Resolution::prompt(PromptAction::DotnetToolUpdate {
500 command: format!(
501 "dotnet tool install -g {} --version {}",
502 dt.package, input.expected_version
503 ),
504 }),
505 })
506}
507
508fn name_matches(input: &ResolveInput<'_>, probed: &ProbedVersion) -> bool {
510 match input.expected_name {
511 Some(name) => probed.name == name,
512 None => probed.name == input.binary_name,
513 }
514}
515
516fn env_path(input: &ResolveInput<'_>) -> Option<String> {
518 if let Some(var) = input.env_config.path_var.as_deref() {
519 if let Some(v) = input.env.get(var) {
520 return Some(v.clone());
521 }
522 }
523 if let Some(var) = input.env_config.dir_var.as_deref() {
524 if let Some(dir) = input.env.get(var) {
525 return Some(join_binary(dir, input.binary_name, input.platform));
526 }
527 }
528 None
529}
530
531fn join_binary(dir: &str, name: &str, platform: Platform) -> String {
533 let trimmed = dir.trim_end_matches(['/', '\\']);
534 let sep = if matches!(platform, Platform::Win32X64 | Platform::Win32Arm64) {
535 '\\'
536 } else {
537 '/'
538 };
539 format!("{trimmed}{sep}{name}{}", platform.exe_suffix())
540}
541
542fn pkgmgr_commands(pkg: &PkgmgrConfig) -> HashMap<String, String> {
544 let mut map = HashMap::new();
545 if let Some(b) = pkg.brew.as_deref() {
546 for p in ["darwin-arm64", "darwin-x64", "linux-x64", "linux-arm64"] {
547 let _ = map.insert(p.to_string(), format!("brew install {b}"));
548 }
549 }
550 if let Some(s) = pkg.scoop.as_deref() {
551 let _ = map.insert("win32-x64".to_string(), format!("scoop install {s}"));
552 let _ = map.insert("win32-arm64".to_string(), format!("scoop install {s}"));
553 }
554 map
555}