From f6e1462df61fc78c427612371affc2ecea10197d Mon Sep 17 00:00:00 2001 From: J Robert Ray Date: Fri, 7 Aug 2026 16:45:42 -0700 Subject: [PATCH 1/5] Skip missing objects in find-path traversal When `spfs info /spfs/...` is run against a selected repo, runtime stack entries and child references can legitimately be missing from that repo (for example local-only vs origin-only objects). Treat `UnknownObject` as a non-fatal miss while walking stack and child references, so the command continues searching and reports providers that do exist in the chosen repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: J Robert Ray --- crates/spfs/src/find_path.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/spfs/src/find_path.rs b/crates/spfs/src/find_path.rs index 144de00a2e..fb3dd2d4fe 100644 --- a/crates/spfs/src/find_path.rs +++ b/crates/spfs/src/find_path.rs @@ -49,7 +49,14 @@ pub async fn find_path_providers_in_spfs_runtime( if let Ok(runtime) = status::active_runtime().await { for digest in runtime.status.stack.iter_bottom_up() { - let item = repo.read_object(digest).await?; + let item = match repo.read_object(digest).await { + Ok(item) => item, + // The selected repo may not have every object in the active + // runtime stack (for example local-only or origin-only + // objects); skip missing ones and keep searching. + Err(Error::UnknownObject(_)) => continue, + Err(err) => return Err(err), + }; let file_data = find_path_in_spfs_item(filepath, &item, repo).await?; if !file_data.is_empty() { found.extend(file_data); @@ -77,7 +84,12 @@ async fn find_path_in_spfs_item( match obj.to_enum() { graph::object::Enum::Platform(obj) => { for reference in obj.iter_bottom_up() { - let item = repo.read_object(*reference).await?; + let item = match repo.read_object(*reference).await { + Ok(item) => item, + // Some child objects may exist only in a different repo. + Err(Error::UnknownObject(_)) => continue, + Err(err) => return Err(err), + }; let paths_to_file = find_path_in_spfs_item(filepath, &item, repo).await?; for path in paths_to_file { let mut new_path: ObjectPath = Vec::new(); @@ -90,7 +102,12 @@ async fn find_path_in_spfs_item( graph::object::Enum::Layer(obj) => { if let Some(manifest_digest) = obj.manifest() { - let item = repo.read_object(*manifest_digest).await?; + let item = match repo.read_object(*manifest_digest).await { + Ok(item) => item, + // The manifest might not exist in this repo. + Err(Error::UnknownObject(_)) => return Ok(paths), + Err(err) => return Err(err), + }; let paths_to_file = find_path_in_spfs_item(filepath, &item, repo).await?; for path in paths_to_file { let mut new_path: ObjectPath = Vec::new(); From 362c1b0a654feed9ad7216f240d263d790a68277 Mon Sep 17 00:00:00 2001 From: J Robert Ray Date: Fri, 7 Aug 2026 16:51:38 -0700 Subject: [PATCH 2/5] Add origin/local fallback mode for info Add `spfs info --origin-local-fallback` to read objects through a proxy over both local and origin repositories. The selected repo remains primary (`local` by default or `--remote origin`), and the other repo is used as a fallback for missing objects so mixed-runtime stacks no longer fail on unknown objects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: J Robert Ray --- crates/spfs-cli/main/src/cmd_info.rs | 34 ++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/spfs-cli/main/src/cmd_info.rs b/crates/spfs-cli/main/src/cmd_info.rs index 8e463f90e3..3c33d4617d 100644 --- a/crates/spfs-cli/main/src/cmd_info.rs +++ b/crates/spfs-cli/main/src/cmd_info.rs @@ -7,7 +7,8 @@ use std::collections::VecDeque; use clap::Args; use colored::*; use futures::TryFutureExt; -use miette::Result; +use miette::{Result, miette}; +use spfs::config::ToAddress; use spfs::env::SPFS_DIR; use spfs::find_path::ObjectPathEntry; use spfs::graph::Annotation; @@ -32,6 +33,13 @@ pub struct CmdInfo { #[clap(flatten)] pub(crate) repos: cli::Repositories, + /// When set, use local/origin as fallback pair for object reads. + /// + /// With no --remote, local is primary and origin is fallback. + /// With --remote origin, origin is primary and local is fallback. + #[clap(long)] + origin_local_fallback: bool, + /// Tag, id, or /spfs/file/path to show information about #[clap(value_name = "REF")] refs: Vec, @@ -56,8 +64,30 @@ pub struct CmdInfo { impl CmdInfo { pub async fn run(&mut self, config: &spfs::Config) -> Result { - let repo = + let primary_repo = spfs::config::open_repository_from_string(config, self.repos.remote.as_ref()).await?; + let repo = if self.origin_local_fallback { + let secondary_repo = match self.repos.remote.as_deref() { + None => spfs::config::open_repository_from_string(config, Some("origin")).await?, + Some("origin") => { + spfs::config::open_repository_from_string(config, Option::<&str>::None).await? + } + Some(other) => { + return Err(miette!( + "--origin-local-fallback only supports local or --remote origin, got --remote {other}" + )); + } + }; + + let proxy_config = spfs::storage::proxy::Config { + primary: primary_repo.address().to_string(), + secondary: vec![secondary_repo.address().to_string()], + include_secondary_tags: false, + }; + spfs::open_repository(proxy_config.to_address()?).await? + } else { + primary_repo + }; self.to_process.extend(self.refs.iter().cloned()); From f546a5dfc665300663cd64813277fdb2e0d26fab Mon Sep 17 00:00:00 2001 From: J Robert Ray Date: Fri, 7 Aug 2026 17:34:25 -0700 Subject: [PATCH 3/5] Hint fallback on unresolved info paths When `spfs info /spfs/...` cannot find a provider and unknown objects were skipped during traversal, print a user-facing hint that extra repositories may be needed and suggest `--origin-local-fallback`. Add a diagnostics variant of runtime path-provider lookup so callers can detect whether unknown objects were encountered while searching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: J Robert Ray --- crates/spfs-cli/main/src/cmd_info.rs | 33 ++++++++---- crates/spfs/src/find_path.rs | 78 +++++++++++++++++++++++----- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/crates/spfs-cli/main/src/cmd_info.rs b/crates/spfs-cli/main/src/cmd_info.rs index 3c33d4617d..c9a5a4438e 100644 --- a/crates/spfs-cli/main/src/cmd_info.rs +++ b/crates/spfs-cli/main/src/cmd_info.rs @@ -299,15 +299,23 @@ impl CmdInfo { verbosity: usize, ) -> Result<()> { let mut in_a_runtime = true; - let found = match spfs::find_path::find_path_providers_in_spfs_runtime(filepath, repo).await - { - Ok(f) => f, - Err(spfs::Error::NoActiveRuntime) => { - in_a_runtime = false; - Vec::new() - } - Err(err) => return Err(err.into()), - }; + let search_result = + match spfs::find_path::find_path_providers_in_spfs_runtime_with_diagnostics( + filepath, repo, + ) + .await + { + Ok(result) => result, + Err(spfs::Error::NoActiveRuntime) => { + in_a_runtime = false; + spfs::find_path::FindPathProvidersResult { + providers: Vec::new(), + skipped_unknown_objects: false, + } + } + Err(err) => return Err(err.into()), + }; + let found = search_result.providers; if found.is_empty() { println!("{filepath}: {}", "not found".yellow()); @@ -319,6 +327,13 @@ impl CmdInfo { "No active runtime".red() } ); + if in_a_runtime && search_result.skipped_unknown_objects { + println!( + " - {}", + "some runtime objects were unavailable in this repo; enable extra repos (try --origin-local-fallback)" + .yellow() + ); + } } else { if let Some(first_path) = found.first() && let Some(ObjectPathEntry::FilePath(file_entry)) = first_path.last() diff --git a/crates/spfs/src/find_path.rs b/crates/spfs/src/find_path.rs index fb3dd2d4fe..17888b1b99 100644 --- a/crates/spfs/src/find_path.rs +++ b/crates/spfs/src/find_path.rs @@ -36,16 +36,31 @@ impl ObjectPathEntry { pub type ObjectPath = Vec; +/// Result data from searching for providers of a path in the active runtime. +pub struct FindPathProvidersResult { + /// Paths to providers found in the active runtime. + pub providers: Vec, + /// Whether objects were skipped because they were not present in the + /// selected repository while searching. + pub skipped_unknown_objects: bool, +} + +struct FindPathInItemResult { + paths: Vec, + skipped_unknown_objects: bool, +} + /// Finds all the spfs object paths to the objects that provide the /// entry for the given filepaths in the current spfs runtime. /// Returns tuple of a boolean for whether we are in an active spfs /// runtime or not, and a list of all the spfs object paths (as lists) /// that end in the entry for the given filepath. -pub async fn find_path_providers_in_spfs_runtime( +pub async fn find_path_providers_in_spfs_runtime_with_diagnostics( filepath: &str, repo: &storage::RepositoryHandle, -) -> Result> { +) -> Result { let mut found: Vec = Vec::new(); + let mut skipped_unknown_objects = false; if let Ok(runtime) = status::active_runtime().await { for digest in runtime.status.stack.iter_bottom_up() { @@ -54,19 +69,40 @@ pub async fn find_path_providers_in_spfs_runtime( // The selected repo may not have every object in the active // runtime stack (for example local-only or origin-only // objects); skip missing ones and keep searching. - Err(Error::UnknownObject(_)) => continue, + Err(Error::UnknownObject(_)) => { + skipped_unknown_objects = true; + continue; + } Err(err) => return Err(err), }; let file_data = find_path_in_spfs_item(filepath, &item, repo).await?; - if !file_data.is_empty() { - found.extend(file_data); + if file_data.skipped_unknown_objects { + skipped_unknown_objects = true; + } + if !file_data.paths.is_empty() { + found.extend(file_data.paths); } } } else { return Err(Error::NoActiveRuntime); } - Ok(found) + Ok(FindPathProvidersResult { + providers: found, + skipped_unknown_objects, + }) +} + +/// Finds all spfs object paths that provide the filepath in the current runtime. +pub async fn find_path_providers_in_spfs_runtime( + filepath: &str, + repo: &storage::RepositoryHandle, +) -> Result> { + Ok( + find_path_providers_in_spfs_runtime_with_diagnostics(filepath, repo) + .await? + .providers, + ) } /// Returns a list of spfs object paths (as lists) from the given spfs @@ -78,8 +114,9 @@ async fn find_path_in_spfs_item( filepath: &str, obj: &Object, repo: &storage::RepositoryHandle, -) -> Result> { +) -> Result { let mut paths: Vec = Vec::new(); + let mut skipped_unknown_objects = false; match obj.to_enum() { graph::object::Enum::Platform(obj) => { @@ -87,11 +124,17 @@ async fn find_path_in_spfs_item( let item = match repo.read_object(*reference).await { Ok(item) => item, // Some child objects may exist only in a different repo. - Err(Error::UnknownObject(_)) => continue, + Err(Error::UnknownObject(_)) => { + skipped_unknown_objects = true; + continue; + } Err(err) => return Err(err), }; let paths_to_file = find_path_in_spfs_item(filepath, &item, repo).await?; - for path in paths_to_file { + if paths_to_file.skipped_unknown_objects { + skipped_unknown_objects = true; + } + for path in paths_to_file.paths { let mut new_path: ObjectPath = Vec::new(); new_path.push(ObjectPathEntry::Parent(obj.to_object())); new_path.extend(path); @@ -105,11 +148,19 @@ async fn find_path_in_spfs_item( let item = match repo.read_object(*manifest_digest).await { Ok(item) => item, // The manifest might not exist in this repo. - Err(Error::UnknownObject(_)) => return Ok(paths), + Err(Error::UnknownObject(_)) => { + return Ok(FindPathInItemResult { + paths, + skipped_unknown_objects: true, + }); + } Err(err) => return Err(err), }; let paths_to_file = find_path_in_spfs_item(filepath, &item, repo).await?; - for path in paths_to_file { + if paths_to_file.skipped_unknown_objects { + skipped_unknown_objects = true; + } + for path in paths_to_file.paths { let mut new_path: ObjectPath = Vec::new(); new_path.push(ObjectPathEntry::Parent(obj.to_object())); new_path.extend(path); @@ -139,5 +190,8 @@ async fn find_path_in_spfs_item( } }; - Ok(paths) + Ok(FindPathInItemResult { + paths, + skipped_unknown_objects, + }) } From bab55ed28b62c990622758ee3a767e92b10e029c Mon Sep 17 00:00:00 2001 From: J Robert Ray Date: Fri, 7 Aug 2026 17:50:32 -0700 Subject: [PATCH 4/5] Add coverage for info fallback hints Rebase the branch onto current main and add targeted tests around new path-provider diagnostics and info hint behavior. The new tests cover the no-active-runtime path lookup failure mode and the hint emission predicate used by `spfs info` when unknown objects were skipped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: J Robert Ray --- crates/spfs-cli/main/src/cmd_info.rs | 64 +++++++++++++++++++++++++--- crates/spfs/src/find_path.rs | 45 +++++++++++++++++++ 2 files changed, 103 insertions(+), 6 deletions(-) diff --git a/crates/spfs-cli/main/src/cmd_info.rs b/crates/spfs-cli/main/src/cmd_info.rs index c9a5a4438e..9fc8add729 100644 --- a/crates/spfs-cli/main/src/cmd_info.rs +++ b/crates/spfs-cli/main/src/cmd_info.rs @@ -327,12 +327,11 @@ impl CmdInfo { "No active runtime".red() } ); - if in_a_runtime && search_result.skipped_unknown_objects { - println!( - " - {}", - "some runtime objects were unavailable in this repo; enable extra repos (try --origin-local-fallback)" - .yellow() - ); + if let Some(hint) = missing_file_provider_hint(MissingProviderContext { + in_a_runtime, + skipped_unknown_objects: search_result.skipped_unknown_objects, + }) { + println!(" - {}", hint.yellow()); } } else { if let Some(first_path) = found.first() @@ -366,3 +365,56 @@ impl CmdInfo { Ok(()) } } + +struct MissingProviderContext { + in_a_runtime: bool, + skipped_unknown_objects: bool, +} + +fn missing_file_provider_hint(ctx: MissingProviderContext) -> Option<&'static str> { + if ctx.in_a_runtime && ctx.skipped_unknown_objects { + Some( + "some runtime objects were unavailable in this repo; enable extra repos (try --origin-local-fallback)", + ) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::{MissingProviderContext, missing_file_provider_hint}; + + #[test] + fn hint_when_runtime_has_unknown_objects() { + assert!( + missing_file_provider_hint(MissingProviderContext { + in_a_runtime: true, + skipped_unknown_objects: true + }) + .is_some() + ); + } + + #[test] + fn no_hint_without_runtime() { + assert!( + missing_file_provider_hint(MissingProviderContext { + in_a_runtime: false, + skipped_unknown_objects: true + }) + .is_none() + ); + } + + #[test] + fn no_hint_without_unknown_objects() { + assert!( + missing_file_provider_hint(MissingProviderContext { + in_a_runtime: true, + skipped_unknown_objects: false + }) + .is_none() + ); + } +} diff --git a/crates/spfs/src/find_path.rs b/crates/spfs/src/find_path.rs index 17888b1b99..49c86e2d0d 100644 --- a/crates/spfs/src/find_path.rs +++ b/crates/spfs/src/find_path.rs @@ -37,6 +37,7 @@ impl ObjectPathEntry { pub type ObjectPath = Vec; /// Result data from searching for providers of a path in the active runtime. +#[derive(Debug)] pub struct FindPathProvidersResult { /// Paths to providers found in the active runtime. pub providers: Vec, @@ -195,3 +196,47 @@ async fn find_path_in_spfs_item( skipped_unknown_objects, }) } + +#[cfg(test)] +mod tests { + use rstest::rstest; + use serial_test::serial; + + use super::*; + use crate::fixtures::{TempRepo, tmprepo}; + + #[rstest] + #[tokio::test] + #[serial(env)] + async fn reports_no_active_runtime(#[future] tmprepo: TempRepo) { + let repo = tmprepo.await; + let runtime_env = "SPFS_RUNTIME"; + let saved_runtime = std::env::var_os(runtime_env); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + std::env::remove_var(runtime_env); + } + + let diagnostics_err = + find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) + .await + .expect_err("expected no active runtime error"); + assert!(matches!(diagnostics_err, Error::NoActiveRuntime)); + + let legacy_err = find_path_providers_in_spfs_runtime("/spfs/does-not-exist", &repo) + .await + .expect_err("expected no active runtime error"); + assert!(matches!(legacy_err, Error::NoActiveRuntime)); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + match saved_runtime { + Some(val) => std::env::set_var(runtime_env, val), + None => std::env::remove_var(runtime_env), + } + } + } +} From 28d1968901b7503139636fe9fd81ef2b792f0f7a Mon Sep 17 00:00:00 2001 From: J Robert Ray Date: Fri, 7 Aug 2026 18:36:20 -0700 Subject: [PATCH 5/5] Use separate behavior tests for info path lookup Move new test coverage into dedicated `*_test.rs` modules to match project conventions. Replace low-signal branch-only tests with runtime behavior tests that exercise meaningful lookup outcomes: no active runtime, missing stack objects, missing layer manifests, and empty runtime stack handling. Keep the info hint assertion focused on user-visible fallback guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: J Robert Ray --- crates/spfs-cli/main/src/cmd_info.rs | 42 +---- crates/spfs-cli/main/src/cmd_info_test.rs | 33 ++++ crates/spfs/src/find_path.rs | 48 +----- crates/spfs/src/find_path_test.rs | 184 ++++++++++++++++++++++ 4 files changed, 225 insertions(+), 82 deletions(-) create mode 100644 crates/spfs-cli/main/src/cmd_info_test.rs create mode 100644 crates/spfs/src/find_path_test.rs diff --git a/crates/spfs-cli/main/src/cmd_info.rs b/crates/spfs-cli/main/src/cmd_info.rs index 9fc8add729..43d0244ef4 100644 --- a/crates/spfs-cli/main/src/cmd_info.rs +++ b/crates/spfs-cli/main/src/cmd_info.rs @@ -17,6 +17,10 @@ use spfs::prelude::*; use spfs::{self}; use spfs_cli_common as cli; +#[cfg(test)] +#[path = "./cmd_info_test.rs"] +mod cmd_info_test; + /// Display information about the current environment, or specific items #[derive(Debug, Args)] pub struct CmdInfo { @@ -380,41 +384,3 @@ fn missing_file_provider_hint(ctx: MissingProviderContext) -> Option<&'static st None } } - -#[cfg(test)] -mod tests { - use super::{MissingProviderContext, missing_file_provider_hint}; - - #[test] - fn hint_when_runtime_has_unknown_objects() { - assert!( - missing_file_provider_hint(MissingProviderContext { - in_a_runtime: true, - skipped_unknown_objects: true - }) - .is_some() - ); - } - - #[test] - fn no_hint_without_runtime() { - assert!( - missing_file_provider_hint(MissingProviderContext { - in_a_runtime: false, - skipped_unknown_objects: true - }) - .is_none() - ); - } - - #[test] - fn no_hint_without_unknown_objects() { - assert!( - missing_file_provider_hint(MissingProviderContext { - in_a_runtime: true, - skipped_unknown_objects: false - }) - .is_none() - ); - } -} diff --git a/crates/spfs-cli/main/src/cmd_info_test.rs b/crates/spfs-cli/main/src/cmd_info_test.rs new file mode 100644 index 0000000000..a628ef2d4e --- /dev/null +++ b/crates/spfs-cli/main/src/cmd_info_test.rs @@ -0,0 +1,33 @@ +// Copyright (c) Contributors to the SPK project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/spkenv/spk + +use super::{MissingProviderContext, missing_file_provider_hint}; + +#[test] +fn suggests_origin_local_fallback_for_split_runtime_objects() { + let hint = missing_file_provider_hint(MissingProviderContext { + in_a_runtime: true, + skipped_unknown_objects: true, + }) + .expect("expected hint text"); + assert!(hint.contains("--origin-local-fallback")); +} + +#[test] +fn omits_fallback_hint_when_lookup_cannot_be_influenced_by_repo_selection() { + assert!( + missing_file_provider_hint(MissingProviderContext { + in_a_runtime: false, + skipped_unknown_objects: true + }) + .is_none() + ); + assert!( + missing_file_provider_hint(MissingProviderContext { + in_a_runtime: true, + skipped_unknown_objects: false + }) + .is_none() + ); +} diff --git a/crates/spfs/src/find_path.rs b/crates/spfs/src/find_path.rs index 49c86e2d0d..8613e8e415 100644 --- a/crates/spfs/src/find_path.rs +++ b/crates/spfs/src/find_path.rs @@ -10,6 +10,10 @@ use spfs_encoding::prelude::*; use crate::graph::{self, DatabaseView, Object}; use crate::{Error, Result, env, status, storage, tracking}; +#[cfg(test)] +#[path = "./find_path_test.rs"] +mod find_path_test; + /// Used for items in a list of spfs objects that contain a filepath. /// The parent containers down to the filepath will be graph objects. /// The filepath itself will be a manifest node entry. @@ -196,47 +200,3 @@ async fn find_path_in_spfs_item( skipped_unknown_objects, }) } - -#[cfg(test)] -mod tests { - use rstest::rstest; - use serial_test::serial; - - use super::*; - use crate::fixtures::{TempRepo, tmprepo}; - - #[rstest] - #[tokio::test] - #[serial(env)] - async fn reports_no_active_runtime(#[future] tmprepo: TempRepo) { - let repo = tmprepo.await; - let runtime_env = "SPFS_RUNTIME"; - let saved_runtime = std::env::var_os(runtime_env); - - // Safety: process environment is shared mutable state. This test uses - // serial(env) so it does not race with other env-mutating tests. - unsafe { - std::env::remove_var(runtime_env); - } - - let diagnostics_err = - find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) - .await - .expect_err("expected no active runtime error"); - assert!(matches!(diagnostics_err, Error::NoActiveRuntime)); - - let legacy_err = find_path_providers_in_spfs_runtime("/spfs/does-not-exist", &repo) - .await - .expect_err("expected no active runtime error"); - assert!(matches!(legacy_err, Error::NoActiveRuntime)); - - // Safety: process environment is shared mutable state. This test uses - // serial(env) so it does not race with other env-mutating tests. - unsafe { - match saved_runtime { - Some(val) => std::env::set_var(runtime_env, val), - None => std::env::remove_var(runtime_env), - } - } - } -} diff --git a/crates/spfs/src/find_path_test.rs b/crates/spfs/src/find_path_test.rs new file mode 100644 index 0000000000..4348d616f9 --- /dev/null +++ b/crates/spfs/src/find_path_test.rs @@ -0,0 +1,184 @@ +// Copyright (c) Contributors to the SPK project. +// SPDX-License-Identifier: Apache-2.0 +// https://github.com/spkenv/spk + +use rstest::rstest; +use serial_test::serial; + +use crate::fixtures::{TempRepo, tmprepo}; +use crate::prelude::*; +use crate::{Error, encoding, graph, runtime, storage}; + +#[rstest] +#[tokio::test] +#[serial(env)] +async fn reports_no_active_runtime(#[future] tmprepo: TempRepo) { + let repo = tmprepo.await; + let runtime_env = "SPFS_RUNTIME"; + let saved_runtime = std::env::var_os(runtime_env); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + std::env::remove_var(runtime_env); + } + + let diagnostics_err = + super::find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) + .await + .expect_err("expected no active runtime error"); + assert!(matches!(diagnostics_err, Error::NoActiveRuntime)); + + let legacy_err = super::find_path_providers_in_spfs_runtime("/spfs/does-not-exist", &repo) + .await + .expect_err("expected no active runtime error"); + assert!(matches!(legacy_err, Error::NoActiveRuntime)); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + match saved_runtime { + Some(val) => std::env::set_var(runtime_env, val), + None => std::env::remove_var(runtime_env), + } + } +} + +#[tokio::test] +#[serial(env)] +async fn marks_skipped_unknown_for_missing_runtime_stack_object() { + let config = crate::get_config().expect("get config"); + let fs_repo = config + .get_opened_local_repository() + .await + .expect("open local repository"); + let repo = storage::RepositoryHandle::from(fs_repo.clone()); + let storage = runtime::Storage::new(fs_repo).expect("create runtime storage"); + let mut runtime = storage + .create_owned_runtime() + .await + .expect("create owned runtime"); + runtime.push_digest(encoding::NULL_DIGEST.into()); + runtime + .save_state_to_storage() + .await + .expect("save runtime state"); + + let runtime_env = "SPFS_RUNTIME"; + let saved_runtime = std::env::var_os(runtime_env); + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + std::env::set_var(runtime_env, runtime.name()); + } + + let result = + super::find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) + .await + .expect("search should complete"); + assert!(result.providers.is_empty()); + assert!(result.skipped_unknown_objects); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + match saved_runtime { + Some(val) => std::env::set_var(runtime_env, val), + None => std::env::remove_var(runtime_env), + } + } +} + +#[tokio::test] +#[serial(env)] +async fn marks_skipped_unknown_for_missing_layer_manifest_object() { + let config = crate::get_config().expect("get config"); + let fs_repo = config + .get_opened_local_repository() + .await + .expect("open local repository"); + let repo = storage::RepositoryHandle::from(fs_repo.clone()); + let storage = runtime::Storage::new(fs_repo).expect("create runtime storage"); + let mut runtime = storage + .create_owned_runtime() + .await + .expect("create owned runtime"); + + let layer = graph::Layer::new(encoding::NULL_DIGEST.into()); + repo.write_object(&layer) + .await + .expect("write layer with missing manifest"); + runtime.push_digest(layer.digest().expect("get layer digest for stack")); + runtime + .save_state_to_storage() + .await + .expect("save runtime state"); + + let runtime_env = "SPFS_RUNTIME"; + let saved_runtime = std::env::var_os(runtime_env); + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + std::env::set_var(runtime_env, runtime.name()); + } + + let result = + super::find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) + .await + .expect("search should complete"); + assert!(result.providers.is_empty()); + assert!(result.skipped_unknown_objects); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + match saved_runtime { + Some(val) => std::env::set_var(runtime_env, val), + None => std::env::remove_var(runtime_env), + } + } +} + +#[tokio::test] +#[serial(env)] +async fn does_not_mark_skipped_unknown_when_runtime_stack_is_empty() { + let config = crate::get_config().expect("get config"); + let fs_repo = config + .get_opened_local_repository() + .await + .expect("open local repository"); + let repo = storage::RepositoryHandle::from(fs_repo.clone()); + let storage = runtime::Storage::new(fs_repo).expect("create runtime storage"); + let runtime = storage + .create_owned_runtime() + .await + .expect("create owned runtime"); + runtime + .save_state_to_storage() + .await + .expect("save runtime state"); + + let runtime_env = "SPFS_RUNTIME"; + let saved_runtime = std::env::var_os(runtime_env); + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + std::env::set_var(runtime_env, runtime.name()); + } + + let result = + super::find_path_providers_in_spfs_runtime_with_diagnostics("/spfs/does-not-exist", &repo) + .await + .expect("search should complete"); + assert!(result.providers.is_empty()); + assert!(!result.skipped_unknown_objects); + + // Safety: process environment is shared mutable state. This test uses + // serial(env) so it does not race with other env-mutating tests. + unsafe { + match saved_runtime { + Some(val) => std::env::set_var(runtime_env, val), + None => std::env::remove_var(runtime_env), + } + } +}