Skip to main content

rightkit_ort/
lib.rs

1//! Product-neutral ONNX Runtime dynamic-library resolution, environment and
2//! session setup for Right Suite apps.
3//!
4//! Merged from ScrapeRight `ort_common` (installed-bundle layout, system-DLL
5//! hazard) and HeardRight `heardright-onnx-asr` (environment, execution
6//! providers, session builder). Product environment variables and data roots
7//! stay in app adapters.
8
9/// The pinned `ort` crate, re-exported so apps that run their own inference reach
10/// `rightkit_ort::ort::{value::Tensor, session::Session, ...}` through this crate.
11/// They never declare a direct `ort` dependency, which keeps the release ownership
12/// scanner from seeing one and keeps the whole suite on a single `ort` pin.
13///
14/// ```no_run
15/// use rightkit_ort::ort::{session::Session, value::Tensor};
16///
17/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
18/// // Configure the runtime and environment first (see `configure_runtime`).
19/// let mut session = Session::builder()?.commit_from_file("model.onnx")?;
20/// let x = Tensor::from_array(([1usize, 4], vec![0.0f32; 4]))?;
21/// let _outputs = session.run(rightkit_ort::ort::inputs!["x" => x])?;
22/// # Ok(())
23/// # }
24/// ```
25#[cfg(feature = "session")]
26pub use ort;
27
28/// `half` (f16/bf16) as used by ort's `half` feature: `rightkit_ort::half::f16`.
29#[cfg(feature = "half")]
30pub use half;
31
32/// `ndarray` as used by ort's `ndarray` feature: `rightkit_ort::ndarray::Array2`.
33#[cfg(feature = "ndarray")]
34pub use ndarray;
35
36#[cfg(feature = "session")]
37pub mod environment;
38#[cfg(feature = "session")]
39pub mod probe;
40#[cfg(feature = "session")]
41pub mod session;
42
43#[cfg(feature = "session")]
44pub use environment::{
45    cpu_thread_budget, init_environment, init_environment_with_options, shared_pool_active,
46    EnvironmentInitOptions, EnvironmentInitReport, EnvironmentOptions, EnvironmentReport,
47    EnvironmentStatus, GlobalPool,
48};
49#[cfg(feature = "session")]
50pub use probe::{probe_providers, ProviderDiagnostic, ProviderKind};
51#[cfg(feature = "session")]
52pub use session::{BuiltSession, ExecutionProvider, SessionError, SessionOptions};
53
54use std::fmt;
55use std::path::{Path, PathBuf};
56use std::sync::Mutex;
57
58static CONFIGURED_RUNTIME: Mutex<Option<PathBuf>> = Mutex::new(None);
59
60/// Clear inherited `ORT_DYLIB_PATH` in release builds; no-op in debug builds.
61/// This keeps shell-exported development paths from steering shipped apps,
62/// consistent with the policy that inherited `ORT_DYLIB_PATH` fails closed.
63///
64/// Call at process startup, before any thread spawns, runtime binding or ORT
65/// use. Removing a process environment variable requires exclusive access to
66/// the environment; this function does not unload an already-loaded runtime.
67pub fn clear_inherited_runtime_for_release() {
68    clear_inherited_runtime_with(|| std::env::remove_var("ORT_DYLIB_PATH"));
69}
70
71// Injected removal keeps release/debug tests free of process env mutation.
72fn clear_inherited_runtime_with(remove_path: impl FnOnce()) {
73    if cfg!(not(debug_assertions)) {
74        remove_path();
75    }
76}
77
78/// Read a caller-named development override as an explicit runtime candidate.
79/// Unset or empty values return `None`; release builds always return `None`
80/// without reading the variable. Path validation remains in runtime resolution.
81pub fn developer_override_candidate(env_name: &str) -> Option<RuntimeCandidate> {
82    if cfg!(debug_assertions) {
83        nonempty_env_path(env_name)
84            .map(|path| RuntimeCandidate::new(path, CandidateSource::Explicit))
85    } else {
86        None
87    }
88}
89
90/// Return the last path successfully bound by this crate for this process,
91/// falling back to a non-empty `ORT_DYLIB_PATH` when no binding is recorded.
92/// Read-only: no resolution, file check, environment mutation or ORT loading.
93/// Missing-mode bindings are included; this is not proof of a loaded library.
94pub fn configured_runtime() -> Option<PathBuf> {
95    let recorded = CONFIGURED_RUNTIME
96        .lock()
97        .unwrap_or_else(|poisoned| poisoned.into_inner())
98        .clone();
99    recorded.or_else(|| nonempty_env_path("ORT_DYLIB_PATH"))
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103pub enum CandidateSource {
104    Explicit,
105    Bundled,
106    AppData,
107    /// Caller-opted-in `<workspace>/tools/bin/<runtime filename>`.
108    LegacyDefault,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct RuntimeCandidate {
113    pub path: PathBuf,
114    pub source: CandidateSource,
115}
116
117impl RuntimeCandidate {
118    pub fn new(path: impl Into<PathBuf>, source: CandidateSource) -> Self {
119        Self {
120            path: path.into(),
121            source,
122        }
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct CandidateDiagnostic {
128    pub path: PathBuf,
129    pub source: CandidateSource,
130    pub absolute: bool,
131    pub expected_filename: bool,
132    pub exists: bool,
133    pub is_file: bool,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct RuntimeSelection {
138    pub path: PathBuf,
139    pub source: CandidateSource,
140    pub diagnostics: Vec<CandidateDiagnostic>,
141}
142
143/// Binding policy; defaults retain strict file validation & conflict refusal.
144#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
145pub struct RuntimeBindingOptions {
146    /// Overwrite ORT_DYLIB_PATH, including an invalid existing value. This does
147    /// not replace a library already loaded by ort. Use before any ORT use.
148    pub force_replace: bool,
149    /// If no regular file resolves, bind the first missing candidate. Existing
150    /// directories, invalid filenames & unsafe paths remain errors.
151    pub allow_missing: bool,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum RuntimeBindingStatus {
156    Ready,
157    /// Path recorded & bound, but absent. Callers own degraded-mode messaging.
158    Missing,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct RuntimeBinding {
163    pub selection: RuntimeSelection,
164    pub status: RuntimeBindingStatus,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum RuntimeError {
169    NoCandidates,
170    UnsafePath {
171        path: PathBuf,
172    },
173    UnexpectedFilename {
174        path: PathBuf,
175        expected: &'static str,
176    },
177    NotFound {
178        diagnostics: Vec<CandidateDiagnostic>,
179    },
180    Canonicalize {
181        path: PathBuf,
182        message: String,
183    },
184    AlreadyConfigured {
185        configured: PathBuf,
186        selected: PathBuf,
187    },
188    /// The Windows-ML copy in `System32` hangs at session init; apps must
189    /// supply their bundled runtime instead.
190    SystemRuntimeRejected {
191        path: PathBuf,
192    },
193}
194
195impl fmt::Display for RuntimeError {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        match self {
198            Self::NoCandidates => write!(f, "no ONNX Runtime candidates were supplied"),
199            Self::UnsafePath { path } => {
200                write!(f, "runtime path must be absolute: {}", path.display())
201            }
202            Self::UnexpectedFilename { path, expected } => {
203                write!(f, "runtime path {} must end in {expected}", path.display())
204            }
205            Self::NotFound { .. } => {
206                write!(f, "no supplied ONNX Runtime candidate is a regular file")
207            }
208            Self::Canonicalize { path, message } => {
209                write!(f, "cannot canonicalize {}: {message}", path.display())
210            }
211            Self::SystemRuntimeRejected { path } => write!(
212                f,
213                "runtime {} is the OS-provided copy; bundle and supply an app runtime",
214                path.display()
215            ),
216            Self::AlreadyConfigured {
217                configured,
218                selected,
219            } => write!(
220                f,
221                "ORT_DYLIB_PATH is already {}, refusing replacement with {}",
222                configured.display(),
223                selected.display()
224            ),
225        }
226    }
227}
228
229impl std::error::Error for RuntimeError {}
230
231pub fn runtime_filename() -> &'static str {
232    #[cfg(target_os = "windows")]
233    {
234        "onnxruntime.dll"
235    }
236    #[cfg(target_os = "macos")]
237    {
238        "libonnxruntime.dylib"
239    }
240    #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
241    {
242        "libonnxruntime.so"
243    }
244}
245
246pub fn inspect_candidate(candidate: &RuntimeCandidate) -> CandidateDiagnostic {
247    CandidateDiagnostic {
248        path: candidate.path.clone(),
249        source: candidate.source,
250        absolute: candidate.path.is_absolute(),
251        expected_filename: candidate.path.file_name().and_then(|name| name.to_str())
252            == Some(runtime_filename()),
253        exists: candidate.path.exists(),
254        is_file: candidate.path.is_file(),
255    }
256}
257
258pub fn resolve_runtime(
259    candidates: impl IntoIterator<Item = RuntimeCandidate>,
260) -> Result<RuntimeSelection, RuntimeError> {
261    let candidates: Vec<_> = candidates.into_iter().collect();
262    if candidates.is_empty() {
263        return Err(RuntimeError::NoCandidates);
264    }
265    let mut diagnostics = Vec::with_capacity(candidates.len());
266    for candidate in candidates {
267        let diagnostic = inspect_candidate(&candidate);
268        if !diagnostic.absolute {
269            return Err(RuntimeError::UnsafePath {
270                path: candidate.path,
271            });
272        }
273        if !diagnostic.expected_filename {
274            return Err(RuntimeError::UnexpectedFilename {
275                path: candidate.path,
276                expected: runtime_filename(),
277            });
278        }
279        if is_system_runtime(&candidate.path) {
280            return Err(RuntimeError::SystemRuntimeRejected {
281                path: candidate.path,
282            });
283        }
284        diagnostics.push(diagnostic.clone());
285        if diagnostic.is_file {
286            let path =
287                candidate
288                    .path
289                    .canonicalize()
290                    .map_err(|error| RuntimeError::Canonicalize {
291                        path: candidate.path,
292                        message: error.to_string(),
293                    })?;
294            return Ok(RuntimeSelection {
295                path,
296                source: candidate.source,
297                diagnostics,
298            });
299        }
300    }
301    Err(RuntimeError::NotFound { diagnostics })
302}
303
304/// True when the path sits under a Windows `System32`/`SysWOW64` directory.
305/// The OS copy of `onnxruntime.dll` (Windows ML) hangs at session init, so it
306/// is never an acceptable candidate (ScrapeRight `ort_common` finding).
307pub fn is_system_runtime(path: &Path) -> bool {
308    path.components().any(|component| {
309        component
310            .as_os_str()
311            .to_str()
312            .map(|name| {
313                name.eq_ignore_ascii_case("system32") || name.eq_ignore_ascii_case("syswow64")
314            })
315            .unwrap_or(false)
316    })
317}
318
319/// Resources directory of an installed application bundle, derived from its
320/// executable only (no checkout or working-directory fallback):
321/// macOS `Foo.app/Contents/MacOS/foo` -> `Contents/Resources`; Windows the
322/// executable directory; elsewhere `<exe dir>/resources`.
323pub fn installed_resource_dir(executable: &Path) -> Option<PathBuf> {
324    if !executable.is_absolute() {
325        return None;
326    }
327    let dir = executable.parent()?.to_path_buf();
328    let is = |p: &Path, name: &str| {
329        p.file_name()
330            .and_then(|n| n.to_str())
331            .map(|n| n.eq_ignore_ascii_case(name))
332            .unwrap_or(false)
333    };
334    #[cfg(target_os = "macos")]
335    {
336        if is(&dir, "MacOS") {
337            if let Some(contents) = dir.parent() {
338                if is(contents, "Contents") {
339                    return Some(contents.join("Resources"));
340                }
341            }
342        }
343        if is(&dir, "Resources") {
344            return Some(dir);
345        }
346        Some(dir.join("Resources"))
347    }
348    #[cfg(target_os = "windows")]
349    {
350        let _ = is;
351        Some(dir)
352    }
353    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
354    {
355        if is(&dir, "resources") {
356            Some(dir)
357        } else {
358            Some(dir.join("resources"))
359        }
360    }
361}
362
363/// Bundled-runtime candidate for an installed app: `<resources>/<subdir>/<runtime file>`.
364/// Apps pass their own `subdir` (ScrapeRight/HeardRight/CodeRight all use `runtime`).
365pub fn installed_runtime_candidate(executable: &Path, subdir: &str) -> Option<RuntimeCandidate> {
366    let dir = installed_resource_dir(executable)?;
367    Some(RuntimeCandidate::new(
368        dir.join(subdir).join(runtime_filename()),
369        CandidateSource::Bundled,
370    ))
371}
372
373/// Explicit legacy candidate; never searched unless the caller supplies it.
374/// Relative roots are rejected rather than resolved against process cwd.
375pub fn legacy_runtime_candidate(workspace_root: &Path) -> Result<RuntimeCandidate, RuntimeError> {
376    if !workspace_root.is_absolute() {
377        return Err(RuntimeError::UnsafePath {
378            path: workspace_root.to_path_buf(),
379        });
380    }
381    Ok(RuntimeCandidate::new(
382        workspace_root
383            .join("tools")
384            .join("bin")
385            .join(runtime_filename()),
386        CandidateSource::LegacyDefault,
387    ))
388}
389
390/// Configure ORT only after resolving a caller-supplied, absolute regular file.
391/// Existing configuration is preserved and must canonicalize to the same file.
392pub fn configure_runtime(
393    candidates: impl IntoIterator<Item = RuntimeCandidate>,
394) -> Result<RuntimeSelection, RuntimeError> {
395    configure_runtime_with_options(candidates, &RuntimeBindingOptions::default())
396        .map(|binding| binding.selection)
397}
398
399/// Bind explicit candidates under caller-selected policy. Available regular
400/// files win in caller order; missing mode falls back to the first absent path
401/// only when no file resolves. Missing paths retain their supplied absolute
402/// spelling because they cannot be canonicalized.
403///
404/// Call before starting worker threads or using any ORT API: this mutates a
405/// process environment variable, not ort's already-loaded library handle.
406pub fn configure_runtime_with_options(
407    candidates: impl IntoIterator<Item = RuntimeCandidate>,
408    options: &RuntimeBindingOptions,
409) -> Result<RuntimeBinding, RuntimeError> {
410    let binding = configure_runtime_with_env(
411        candidates,
412        options,
413        nonempty_env_path("ORT_DYLIB_PATH"),
414        |path| std::env::set_var("ORT_DYLIB_PATH", path),
415    )?;
416    *CONFIGURED_RUNTIME
417        .lock()
418        .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(binding.selection.path.clone());
419    Ok(binding)
420}
421
422// Injected environment writer avoids process-global mutation in unit tests.
423fn configure_runtime_with_env(
424    candidates: impl IntoIterator<Item = RuntimeCandidate>,
425    options: &RuntimeBindingOptions,
426    configured: Option<PathBuf>,
427    set_path: impl FnOnce(&Path),
428) -> Result<RuntimeBinding, RuntimeError> {
429    let binding = match resolve_runtime(candidates) {
430        Ok(selection) => RuntimeBinding {
431            selection,
432            status: RuntimeBindingStatus::Ready,
433        },
434        Err(RuntimeError::NotFound { diagnostics }) if options.allow_missing => {
435            let Some(missing) = diagnostics.iter().find(|candidate| !candidate.exists) else {
436                return Err(RuntimeError::NotFound { diagnostics });
437            };
438            RuntimeBinding {
439                selection: RuntimeSelection {
440                    path: missing.path.clone(),
441                    source: missing.source,
442                    diagnostics,
443                },
444                status: RuntimeBindingStatus::Missing,
445            }
446        }
447        Err(error) => return Err(error),
448    };
449    if let Some(configured) = configured.filter(|_| !options.force_replace) {
450        if options.allow_missing && configured == binding.selection.path {
451            return Ok(binding);
452        }
453        let configured = canonical_if_file(&configured)?;
454        if configured != binding.selection.path {
455            return Err(RuntimeError::AlreadyConfigured {
456                configured,
457                selected: binding.selection.path,
458            });
459        }
460        return Ok(binding);
461    }
462    set_path(&binding.selection.path);
463    Ok(binding)
464}
465
466/// Ordered runtime search for processes that carry no app-supplied runtime
467/// path (worker processes, CLIs): a non-empty `ORT_DYLIB_PATH`, then
468/// `<exe dir>/runtime`, `<exe dir>`, then each caller directory in order.
469/// Caller directories are joined with `runtime_filename()`; relative
470/// directories are skipped. Candidates are not checked for existence.
471pub fn discovery_candidates(
472    extra_dirs: impl IntoIterator<Item = PathBuf>,
473) -> Vec<RuntimeCandidate> {
474    let mut out = Vec::new();
475    if let Some(path) = nonempty_env_path("ORT_DYLIB_PATH") {
476        out.push(RuntimeCandidate::new(path, CandidateSource::Explicit));
477    }
478    let name = runtime_filename();
479    if let Some(exe_dir) = std::env::current_exe()
480        .ok()
481        .and_then(|exe| exe.parent().map(Path::to_path_buf))
482    {
483        out.push(RuntimeCandidate::new(
484            exe_dir.join("runtime").join(name),
485            CandidateSource::Bundled,
486        ));
487        out.push(RuntimeCandidate::new(
488            exe_dir.join(name),
489            CandidateSource::Bundled,
490        ));
491    }
492    out.extend(
493        extra_dirs
494            .into_iter()
495            .filter(|dir| dir.is_absolute())
496            .map(|dir| RuntimeCandidate::new(dir.join(name), CandidateSource::AppData)),
497    );
498    out
499}
500
501/// First regular-file candidate from `discovery_candidates`, skipping the
502/// Windows-ML `System32` copy. A stale `ORT_DYLIB_PATH` does not mask a runtime
503/// installed in a later location. No environment mutation, no ORT loading.
504pub fn discover_runtime(extra_dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
505    discovery_candidates(extra_dirs)
506        .into_iter()
507        .map(|candidate| candidate.path)
508        .find(|path| path.is_file() && !is_system_runtime(path))
509}
510
511/// Export a discovered runtime to this process: set `ORT_DYLIB_PATH` unless
512/// already set and prepend its directory to `PATH` so dependent libraries load.
513/// Returns the runtime path now in effect. Call single-threaded, before ORT use.
514pub fn export_discovered_runtime(extra_dirs: impl IntoIterator<Item = PathBuf>) -> Option<PathBuf> {
515    if let Some(existing) = nonempty_env_path("ORT_DYLIB_PATH") {
516        return Some(existing);
517    }
518    let path = discover_runtime(extra_dirs)?;
519    std::env::set_var("ORT_DYLIB_PATH", &path);
520    if let (Some(dir), Some(existing)) = (path.parent(), std::env::var_os("PATH")) {
521        let mut paths = vec![dir.to_path_buf()];
522        paths.extend(std::env::split_paths(&existing));
523        if let Ok(joined) = std::env::join_paths(paths) {
524            std::env::set_var("PATH", joined);
525        }
526    }
527    Some(path)
528}
529
530fn nonempty_env_path(name: &str) -> Option<PathBuf> {
531    std::env::var_os(name)
532        .filter(|value| !value.is_empty())
533        .map(PathBuf::from)
534}
535
536fn canonical_if_file(path: &Path) -> Result<PathBuf, RuntimeError> {
537    if !path.is_absolute() {
538        return Err(RuntimeError::UnsafePath {
539            path: path.to_path_buf(),
540        });
541    }
542    path.canonicalize()
543        .map_err(|error| RuntimeError::Canonicalize {
544            path: path.to_path_buf(),
545            message: error.to_string(),
546        })
547}
548
549#[cfg(test)]
550mod discovery_tests {
551    use super::*;
552
553    #[test]
554    fn caller_dirs_follow_exe_locations_and_relative_dirs_are_skipped() {
555        let abs = std::env::temp_dir().join("rightkit-ort-discovery-absent");
556        let candidates = discovery_candidates([PathBuf::from("relative/runtime"), abs.clone()]);
557        let last = candidates.last().expect("caller candidate");
558        assert_eq!(last.path, abs.join(runtime_filename()));
559        assert_eq!(last.source, CandidateSource::AppData);
560        assert!(candidates
561            .iter()
562            .all(|c| !c.path.starts_with("relative/runtime")));
563        let exe_pos = candidates
564            .iter()
565            .position(|c| c.source == CandidateSource::Bundled);
566        assert!(exe_pos.is_none_or(|pos| pos < candidates.len() - 1));
567    }
568
569    #[test]
570    fn discovery_finds_a_regular_file_and_ignores_a_directory() {
571        let root = std::env::temp_dir().join(format!("rightkit-ort-disc-{}", std::process::id()));
572        let dir_hit = root.join("dir");
573        let file_hit = root.join("file");
574        std::fs::create_dir_all(dir_hit.join(runtime_filename())).unwrap();
575        std::fs::create_dir_all(&file_hit).unwrap();
576        std::fs::write(file_hit.join(runtime_filename()), b"x").unwrap();
577        let found = discover_runtime([dir_hit, file_hit.clone()]);
578        // A developer shell may export ORT_DYLIB_PATH; it wins when it is a file.
579        if nonempty_env_path("ORT_DYLIB_PATH").is_none_or(|p| !p.is_file()) {
580            assert_eq!(found, Some(file_hit.join(runtime_filename())));
581        }
582        let _ = std::fs::remove_dir_all(root);
583    }
584}
585
586#[cfg(test)]
587mod binding_tests {
588    use super::*;
589    use std::sync::atomic::{AtomicUsize, Ordering};
590
591    #[cfg(debug_assertions)]
592    #[test]
593    fn clearing_inherited_runtime_is_noop_in_debug() {
594        clear_inherited_runtime_with(|| panic!("debug must preserve inherited runtime"));
595    }
596
597    #[cfg(not(debug_assertions))]
598    #[test]
599    fn clearing_inherited_runtime_removes_it_in_release() {
600        let mut inherited = Some(PathBuf::from("shell-exported-dev-runtime"));
601        clear_inherited_runtime_with(|| inherited = None);
602        assert_eq!(inherited, None);
603    }
604
605    struct Fixture(PathBuf);
606
607    impl Fixture {
608        fn new() -> Self {
609            static NEXT: AtomicUsize = AtomicUsize::new(0);
610            let root = std::env::temp_dir().join(format!(
611                "rightkit-ort-binding-{}-{}",
612                std::process::id(),
613                NEXT.fetch_add(1, Ordering::Relaxed)
614            ));
615            std::fs::create_dir_all(&root).unwrap();
616            Self(root.canonicalize().unwrap())
617        }
618
619        fn candidate(&self, directory: &str, exists: bool) -> RuntimeCandidate {
620            let path = self.0.join(directory).join(runtime_filename());
621            if exists {
622                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
623                std::fs::write(&path, b"not a real ORT binary").unwrap();
624            }
625            RuntimeCandidate::new(path, CandidateSource::Explicit)
626        }
627    }
628
629    impl Drop for Fixture {
630        fn drop(&mut self) {
631            let _ = std::fs::remove_dir_all(&self.0);
632        }
633    }
634
635    #[test]
636    fn binding_defaults_preserve_conflicts_and_require_files() {
637        let options = RuntimeBindingOptions::default();
638        assert!(!options.force_replace);
639        assert!(!options.allow_missing);
640        let fixture = Fixture::new();
641        let first = fixture.candidate("first", true);
642        let second = fixture.candidate("second", true);
643        let error = configure_runtime_with_env([second], &options, Some(first.path), |_| {
644            panic!("must not overwrite")
645        })
646        .unwrap_err();
647        assert!(matches!(error, RuntimeError::AlreadyConfigured { .. }));
648        let error = configure_runtime_with_env(
649            [fixture.candidate("missing", false)],
650            &options,
651            None,
652            |_| panic!("must not bind missing path by default"),
653        )
654        .unwrap_err();
655        assert!(matches!(error, RuntimeError::NotFound { .. }));
656    }
657
658    #[test]
659    fn forced_replacement_overwrites_existing_or_invalid_configuration() {
660        let fixture = Fixture::new();
661        let first = fixture.candidate("first", true);
662        let selected = fixture.candidate("second", true);
663        for configured in [first.path, PathBuf::from("invalid-relative-path")] {
664            let mut written = None;
665            let report = configure_runtime_with_env(
666                [selected.clone()],
667                &RuntimeBindingOptions {
668                    force_replace: true,
669                    ..Default::default()
670                },
671                Some(configured),
672                |path| written = Some(path.to_path_buf()),
673            )
674            .unwrap();
675            let expected = selected.path.canonicalize().unwrap();
676            assert_eq!(written, Some(expected.clone()));
677            assert_eq!(report.selection.path, expected);
678            assert_eq!(report.status, RuntimeBindingStatus::Ready);
679        }
680    }
681
682    #[test]
683    fn missing_mode_binds_records_and_reuses_first_absent_candidate() {
684        let fixture = Fixture::new();
685        let first = fixture.candidate("missing-first", false);
686        let second = fixture.candidate("missing-second", false);
687        let options = RuntimeBindingOptions {
688            allow_missing: true,
689            ..Default::default()
690        };
691        let mut written = None;
692        let report = configure_runtime_with_env([first.clone(), second], &options, None, |path| {
693            written = Some(path.to_path_buf())
694        })
695        .unwrap();
696        assert_eq!(report.status, RuntimeBindingStatus::Missing);
697        assert_eq!(report.selection.path, first.path);
698        assert_eq!(written, Some(first.path.clone()));
699        assert_eq!(report.selection.diagnostics.len(), 2);
700        assert!(report
701            .selection
702            .diagnostics
703            .iter()
704            .all(|d| !d.exists && !d.is_file));
705        let repeated =
706            configure_runtime_with_env([first.clone()], &options, Some(first.path), |_| {
707                panic!("matching binding must be preserved")
708            })
709            .unwrap();
710        assert_eq!(repeated.status, RuntimeBindingStatus::Missing);
711    }
712
713    #[test]
714    fn missing_mode_prefers_available_files_and_never_binds_directories() {
715        let fixture = Fixture::new();
716        let available = fixture.candidate("available", true);
717        let options = RuntimeBindingOptions {
718            allow_missing: true,
719            ..Default::default()
720        };
721        let report = configure_runtime_with_env(
722            [fixture.candidate("absent", false), available.clone()],
723            &options,
724            None,
725            |_| {},
726        )
727        .unwrap();
728        assert_eq!(report.status, RuntimeBindingStatus::Ready);
729        assert_eq!(
730            report.selection.path,
731            available.path.canonicalize().unwrap()
732        );
733        let directory = fixture.candidate("directory", false);
734        std::fs::create_dir_all(&directory.path).unwrap();
735        let error = configure_runtime_with_env([directory], &options, None, |_| {
736            panic!("directory must not be bound")
737        })
738        .unwrap_err();
739        assert!(matches!(error, RuntimeError::NotFound { .. }));
740    }
741
742    #[test]
743    fn forced_missing_binding_overwrites_existing_configuration() {
744        let fixture = Fixture::new();
745        let previous = fixture.candidate("previous", true);
746        let missing = fixture.candidate("missing", false);
747        let mut written = None;
748        let report = configure_runtime_with_env(
749            [missing.clone()],
750            &RuntimeBindingOptions {
751                force_replace: true,
752                allow_missing: true,
753            },
754            Some(previous.path),
755            |path| written = Some(path.to_path_buf()),
756        )
757        .unwrap();
758        assert_eq!(report.status, RuntimeBindingStatus::Missing);
759        assert_eq!(written, Some(missing.path));
760    }
761
762    #[test]
763    fn permissive_options_still_reject_unsafe_paths_and_system_runtime() {
764        let fixture = Fixture::new();
765        let options = RuntimeBindingOptions {
766            force_replace: true,
767            allow_missing: true,
768        };
769        for (candidate, expected) in [
770            (
771                RuntimeCandidate::new(runtime_filename(), CandidateSource::Explicit),
772                "relative",
773            ),
774            (
775                RuntimeCandidate::new(fixture.0.join("wrong.bin"), CandidateSource::Explicit),
776                "filename",
777            ),
778            (fixture.candidate("System32", false), "system"),
779        ] {
780            let error = configure_runtime_with_env([candidate], &options, None, |_| {
781                panic!("unsafe candidate must not be bound")
782            })
783            .unwrap_err();
784            assert!(match expected {
785                "relative" => matches!(error, RuntimeError::UnsafePath { .. }),
786                "filename" => matches!(error, RuntimeError::UnexpectedFilename { .. }),
787                _ => matches!(error, RuntimeError::SystemRuntimeRejected { .. }),
788            });
789        }
790    }
791}