Skip to main content

shipwright_host/
lib.rs

1//! Binary-resolution algorithm for shipwright host libraries.
2//!
3//! Pure function. No I/O. The caller supplies a `probe` closure
4//! (`Path -> Option<ProbedVersion>`), the environment, PATH entries, the
5//! bundled directory, and the product's declared sources. The resolver
6//! returns a [`Resolution`] describing which source succeeded, where, which
7//! version was observed, and a status (ok / ok-with-warning / deferred /
8//! prompt / error).
9//!
10//! Conformance: the vectors in `schemas/test-vectors.json` are exercised by
11//! `tests/conformance.rs`. Every language port of this algorithm must pass
12//! the same vectors bit-for-bit.
13
14#![forbid(unsafe_code)]
15
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18
19/// Ordered discovery sources declared in `shipwright.schema.json`.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum Source {
23    /// Explicit path from an IDE settings key; accepted only on exact match.
24    UserSetting,
25    /// `<TOOL>_BINARY_PATH` or `<TOOL>_BINARY_DIR`; accepted unconditionally.
26    Env,
27    /// PATH scan; accepted only on exact match.
28    Path,
29    /// Bundled inside the IDE extension; accepted unconditionally.
30    Bundled,
31    /// Native package manager (brew/scoop/apt/winget); surfaces as a prompt.
32    Pkgmgr,
33    /// `dotnet tool` global install; surfaces install/update prompts.
34    DotnetTool,
35    /// `npm`-installed bin on PATH.
36    NpmGlobal,
37    /// `~/.cargo/bin` fallback (Basilisk-style).
38    CargoBin,
39    /// Download from a GitHub Release (Basilisk-style, opt-in per tool).
40    GithubRelease,
41    /// Defer version check to LSP `initialize` response (Zed fallback).
42    LspInitialize,
43}
44
45/// Probe result: what `<binary> --version` reports.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ProbedVersion {
48    /// The `name` token emitted by the binary.
49    pub name: String,
50    /// The `semver` token emitted by the binary.
51    pub version: String,
52}
53
54/// Env-var names this component consults.
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct EnvConfig {
57    /// Absolute-file override, e.g. `DESLOP_BINARY_PATH`.
58    #[serde(rename = "pathVar", skip_serializing_if = "Option::is_none")]
59    pub path_var: Option<String>,
60    /// Directory override, e.g. `DESLOP_BINARY_DIR`.
61    #[serde(rename = "dirVar", skip_serializing_if = "Option::is_none")]
62    pub dir_var: Option<String>,
63}
64
65/// Package-manager install targets for the `pkgmgr` source.
66#[derive(Debug, Clone, Default, Serialize, Deserialize)]
67pub struct PkgmgrConfig {
68    /// Homebrew formula identifier (e.g. `example/tap/forge-lsp`).
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub brew: Option<String>,
71    /// Scoop manifest identifier (e.g. `example/forge-lsp`).
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub scoop: Option<String>,
74    /// `apt` package identifier.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub apt: Option<String>,
77    /// `winget` package identifier.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub winget: Option<String>,
80}
81
82/// `dotnet tool` metadata for the `dotnet-tool` source.
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct DotnetToolConfig {
85    /// `NuGet` package id (e.g. `Forge.Sidecar.CSharp`).
86    pub package: String,
87    /// Shim command name (defaults to `package`).
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub command: Option<String>,
90}
91
92/// Runtime inputs for a single resolve call.
93#[derive(Debug, Clone)]
94pub struct ResolveInput<'a> {
95    /// Binary name (argv[0]), without `.exe`.
96    pub binary_name: &'a str,
97    /// Name the binary must self-report; defaults to `binary_name`.
98    pub expected_name: Option<&'a str>,
99    /// Required semver.
100    pub expected_version: &'a str,
101    /// Declared discovery chain (filter on the full `Source` enum).
102    pub sources: &'a [Source],
103    /// Current platform (controls `.exe` suffix + pkgmgr command fan-out).
104    pub platform: Platform,
105
106    /// Path from the IDE settings key, if any.
107    pub user_setting_path: Option<&'a str>,
108    /// Env map probed via `EnvConfig.path_var` / `dir_var`.
109    pub env: &'a HashMap<String, String>,
110    /// Which env vars to read.
111    pub env_config: EnvConfig,
112    /// Expanded PATH entries (not raw `$PATH`; caller already split).
113    pub path_entries: &'a [String],
114    /// Bundled `<extensionRoot>/bin/<platform>` directory.
115    pub bundled_dir: Option<&'a str>,
116    /// Cargo bin location for Zed-style `cargo-bin` fallback.
117    pub cargo_bin: Option<&'a str>,
118
119    /// Optional pkgmgr policy.
120    pub pkgmgr: Option<&'a PkgmgrConfig>,
121    /// Optional dotnet-tool policy.
122    pub dotnet_tool: Option<&'a DotnetToolConfig>,
123}
124
125/// Canonical platform identifiers. Mirrors `schemas/platforms.json`.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
127#[serde(rename_all = "kebab-case")]
128pub enum Platform {
129    /// macOS Apple Silicon.
130    DarwinArm64,
131    /// macOS Intel.
132    DarwinX64,
133    /// Linux `x86_64`.
134    LinuxX64,
135    /// Linux `aarch64`.
136    LinuxArm64,
137    /// Windows `x86_64`.
138    Win32X64,
139    /// Windows `aarch64`.
140    Win32Arm64,
141    /// Platform-agnostic (Node, dotnet tool, jar, Zed WASM).
142    All,
143}
144
145impl Platform {
146    /// Returns `.exe` on Windows, empty string elsewhere.
147    #[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/// High-level outcome category.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
159#[serde(rename_all = "kebab-case")]
160pub enum Status {
161    /// Resolved and version matches.
162    Ok,
163    /// Resolved but with a warning (e.g. bundled drift, env override).
164    OkWithWarning,
165    /// Resolved; version check deferred (Zed LSP initialize).
166    Deferred,
167    /// Host must prompt the user (install / update).
168    Prompt,
169    /// Cannot resolve.
170    Error,
171}
172
173/// Known warning codes.
174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "kebab-case")]
176pub enum WarningCode {
177    /// Env-override binary version differs from expected.
178    EnvVersionMismatch,
179    /// Bundled binary version differs from expected.
180    BundledVersionDrift,
181}
182
183/// Known error codes.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185#[serde(rename_all = "kebab-case")]
186pub enum ErrorCode {
187    /// User-setting path exists but version differs from expected.
188    UserSettingVersionMismatch,
189    /// No declared source produced a candidate.
190    NoSourceResolved,
191    /// A candidate binary self-reports the wrong name.
192    BinaryNameMismatch,
193}
194
195/// Deferred check kinds.
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "kebab-case")]
198pub enum DeferredCheck {
199    /// Version will be verified via LSP `initialize` response.
200    LspInitialize,
201}
202
203/// Actionable prompt payload.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "kebab-case", tag = "kind")]
206pub enum PromptAction {
207    /// Show platform-specific install commands.
208    PkgmgrInstall {
209        /// Map of `platform-id -> install command`.
210        commands: HashMap<String, String>,
211    },
212    /// Run a single `dotnet tool` install/update command.
213    DotnetToolUpdate {
214        /// The command to run.
215        command: String,
216    },
217}
218
219/// Expected-vs-found details for a mismatch error.
220#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
221pub struct ErrorDetails {
222    /// The required version from the product manifest.
223    pub expected: String,
224    /// What the binary reported.
225    pub found: String,
226    /// The path probed.
227    pub at: String,
228}
229
230/// Final output of [`resolve`].
231#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
232pub struct Resolution {
233    /// Which source produced the result, if any.
234    pub source: Option<Source>,
235    /// The resolved path, if any.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub path: Option<String>,
238    /// The version observed, if any.
239    #[serde(skip_serializing_if = "Option::is_none")]
240    pub version: Option<String>,
241    /// Outcome category.
242    pub status: Status,
243    /// Warning code when `status == OkWithWarning`.
244    #[serde(rename = "warningCode", skip_serializing_if = "Option::is_none")]
245    pub warning_code: Option<WarningCode>,
246    /// Error code when `status == Error`.
247    #[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")]
248    pub error_code: Option<ErrorCode>,
249    /// Mismatch details when available.
250    #[serde(rename = "errorDetails", skip_serializing_if = "Option::is_none")]
251    pub error_details: Option<ErrorDetails>,
252    /// Action to show when `status == Prompt`.
253    #[serde(skip_serializing_if = "Option::is_none")]
254    pub action: Option<PromptAction>,
255    /// Which deferred check the host must run when `status == Deferred`.
256    #[serde(rename = "deferredCheck", skip_serializing_if = "Option::is_none")]
257    pub deferred_check: Option<DeferredCheck>,
258}
259
260impl Resolution {
261    /// Construct an `Ok` result.
262    #[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    /// Construct an `OkWithWarning` result.
277    #[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    /// Construct an `Error` result.
285    #[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    /// Construct a `Prompt` result.
300    #[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    /// Construct a `Deferred` result.
315    #[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
331/// The single entry point. Every host library (TS, Kotlin, C#, Dart, Zed)
332/// ports this function and passes `schemas/test-vectors.json` bit-for-bit.
333pub 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
345/// Dispatch a single source to its handler. Returns `None` when the source
346/// produced no candidate and the resolver should fall through to the next.
347fn 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
363/// Handle the `user-setting` source. Mismatch is a hard error.
364fn 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
397/// Handle the `env` source. Accepted unconditionally; warn on mismatch.
398fn 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
419/// Handle the `path` source. Accepted only on exact match.
420fn 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
438/// Handle the `bundled` source. Accepted unconditionally; warn on drift.
439fn 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
461/// Handle the `cargo-bin` source (Basilisk/Zed fallback). Defers version
462/// check to LSP `initialize` since the Zed host cannot spawn subprocesses.
463fn 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
473/// Handle the `pkgmgr` source. Never auto-runs; produces a prompt.
474fn 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
482/// Handle the `dotnet-tool` source. Install/update prompt on miss/mismatch.
483fn 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
508/// Compare the probed binary name against the declared expectation.
509fn 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
516/// Resolve `env` source path: `pathVar` wins over `dirVar`.
517fn 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
531/// Join directory + binary name and append the platform `.exe` suffix.
532fn 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
542/// Expand a `PkgmgrConfig` into a platform -> command map.
543fn 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}