From 5047a141c45d18ef23ddd369fb262ecac867da11 Mon Sep 17 00:00:00 2001 From: main Date: Thu, 19 Mar 2026 23:43:00 -0400 Subject: Broaden UI target path resolution --- crates/fidget-spinner-cli/src/main.rs | 161 +++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 4 deletions(-) (limited to 'crates/fidget-spinner-cli') diff --git a/crates/fidget-spinner-cli/src/main.rs b/crates/fidget-spinner-cli/src/main.rs index 3ad9534..7711cb4 100644 --- a/crates/fidget-spinner-cli/src/main.rs +++ b/crates/fidget-spinner-cli/src/main.rs @@ -19,7 +19,7 @@ use fidget_spinner_store_sqlite::{ CloseExperimentRequest, CreateFrontierRequest, CreateNodeRequest, DefineMetricRequest, DefineRunDimensionRequest, EdgeAttachment, EdgeAttachmentDirection, ListNodesQuery, MetricBestQuery, MetricFieldSource, MetricKeyQuery, MetricRankOrder, ProjectStore, - RemoveSchemaFieldRequest, StoreError, UpsertSchemaFieldRequest, + RemoveSchemaFieldRequest, STORE_DIR_NAME, StoreError, UpsertSchemaFieldRequest, }; use serde::Serialize; use serde_json::{Map, Value, json}; @@ -573,8 +573,10 @@ struct McpWorkerArgs { #[derive(Args)] struct UiServeArgs { - #[command(flatten)] - project: ProjectArg, + /// Path to serve. Accepts a project root, `.fidget_spinner/`, descendants inside it, + /// or a parent directory containing one unique descendant project store. + #[arg(long = "path", alias = "project", default_value = ".")] + path: PathBuf, /// Bind address for the local navigator. #[arg(long, default_value = "127.0.0.1:8913")] bind: SocketAddr, @@ -1106,7 +1108,8 @@ fn run_skill_install(args: SkillInstallArgs) -> Result<(), StoreError> { } fn run_ui_serve(args: UiServeArgs) -> Result<(), StoreError> { - ui::serve(utf8_path(args.project.project), args.bind, args.limit) + let project_root = resolve_ui_project_root(&utf8_path(args.path))?; + ui::serve(project_root, args.bind, args.limit) } fn resolve_bundled_skill( @@ -1137,6 +1140,28 @@ fn open_store(path: &Path) -> Result { ProjectStore::open(utf8_path(path.to_path_buf())) } +fn resolve_ui_project_root(path: &Utf8Path) -> Result { + if let Some(project_root) = fidget_spinner_store_sqlite::discover_project_root(path) { + return Ok(project_root); + } + let candidates = discover_descendant_project_roots(path)?; + match candidates.len() { + 0 => Err(StoreError::MissingProjectStore(path.to_path_buf())), + 1 => candidates + .into_iter() + .next() + .ok_or_else(|| StoreError::MissingProjectStore(path.to_path_buf())), + _ => Err(StoreError::AmbiguousProjectStoreDiscovery { + path: path.to_path_buf(), + candidates: candidates + .iter() + .map(|candidate| candidate.as_str()) + .collect::>() + .join(", "), + }), + } +} + fn open_or_init_store_for_binding(path: &Path) -> Result { let requested_root = utf8_path(path.to_path_buf()); match ProjectStore::open(requested_root.clone()) { @@ -1183,6 +1208,47 @@ fn is_empty_directory(path: &Utf8Path) -> Result { } } +fn discover_descendant_project_roots(path: &Utf8Path) -> Result, StoreError> { + let start = binding_bootstrap_root(path)?; + let mut candidates = BTreeSet::new(); + collect_descendant_project_roots(&start, &mut candidates)?; + Ok(candidates) +} + +fn collect_descendant_project_roots( + path: &Utf8Path, + candidates: &mut BTreeSet, +) -> Result<(), StoreError> { + let metadata = match fs::metadata(path.as_std_path()) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(StoreError::from(error)), + }; + if metadata.is_file() { + return Ok(()); + } + if path.file_name() == Some(STORE_DIR_NAME) { + if let Some(project_root) = path.parent() { + let _ = candidates.insert(project_root.to_path_buf()); + } + return Ok(()); + } + for entry in fs::read_dir(path.as_std_path())? { + let entry = entry?; + let entry_type = entry.file_type()?; + if !entry_type.is_dir() { + continue; + } + let child = utf8_path(entry.path()); + if child.file_name() == Some(STORE_DIR_NAME) { + let _ = candidates.insert(path.to_path_buf()); + continue; + } + collect_descendant_project_roots(&child, candidates)?; + } + Ok(()) +} + fn default_display_name_for_root(project_root: &Utf8Path) -> Result { NonEmptyText::new( project_root @@ -1664,3 +1730,90 @@ impl From for FrontierVerdict { } } } + +#[cfg(test)] +mod tests { + use super::resolve_ui_project_root; + use std::fs; + + use camino::Utf8PathBuf; + use fidget_spinner_core::NonEmptyText; + use fidget_spinner_store_sqlite::{ + PROJECT_CONFIG_NAME, ProjectStore, STORE_DIR_NAME, StoreError, + }; + + fn temp_project_root(label: &str) -> Utf8PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "fidget_spinner_cli_test_{}_{}", + label, + uuid::Uuid::now_v7() + )); + Utf8PathBuf::from(path.to_string_lossy().into_owned()) + } + + #[test] + fn ui_resolver_accepts_state_root_and_descendants() -> Result<(), StoreError> { + let project_root = temp_project_root("ui_resolve_state_root"); + let _store = ProjectStore::init( + &project_root, + NonEmptyText::new("ui dogfood")?, + NonEmptyText::new("local.ui")?, + )?; + let state_root = project_root.join(STORE_DIR_NAME); + let config_path = state_root.join(PROJECT_CONFIG_NAME); + + assert_eq!(resolve_ui_project_root(&state_root)?, project_root); + assert_eq!(resolve_ui_project_root(&config_path)?, project_root); + Ok(()) + } + + #[test] + fn ui_resolver_accepts_unique_descendant_store_from_parent() -> Result<(), StoreError> { + let parent_root = temp_project_root("ui_resolve_parent"); + let nested_project = parent_root.join("nested/libgrid"); + fs::create_dir_all(nested_project.as_std_path())?; + let _store = ProjectStore::init( + &nested_project, + NonEmptyText::new("nested ui dogfood")?, + NonEmptyText::new("local.nested.ui")?, + )?; + + assert_eq!(resolve_ui_project_root(&parent_root)?, nested_project); + Ok(()) + } + + #[test] + fn ui_resolver_rejects_ambiguous_descendant_stores() -> Result<(), StoreError> { + let parent_root = temp_project_root("ui_resolve_ambiguous"); + let alpha_project = parent_root.join("alpha"); + let beta_project = parent_root.join("beta"); + fs::create_dir_all(alpha_project.as_std_path())?; + fs::create_dir_all(beta_project.as_std_path())?; + let _alpha = ProjectStore::init( + &alpha_project, + NonEmptyText::new("alpha")?, + NonEmptyText::new("local.alpha")?, + )?; + let _beta = ProjectStore::init( + &beta_project, + NonEmptyText::new("beta")?, + NonEmptyText::new("local.beta")?, + )?; + + let error = match resolve_ui_project_root(&parent_root) { + Ok(project_root) => { + return Err(StoreError::Io(std::io::Error::other(format!( + "expected ambiguous descendant discovery failure, got {project_root}" + )))); + } + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("multiple descendant project stores") + ); + Ok(()) + } +} -- cgit v1.2.3