From 0c2bcf290faf8009430bc7401f0d6fdbc6ef7992 Mon Sep 17 00:00:00 2001 From: Calvin Grunewald Date: Sat, 31 Oct 2026 13:53:58 -0700 Subject: [PATCH] factory: Relative selectors resolve and unknown ones error instead of running cwd --- src/cli/run.rs | 621 +++++++++++++++++++++++++++++++++++-------- tests/integration.rs | 140 ++++++++++ 2 files changed, 652 insertions(+), 109 deletions(-) diff --git a/src/cli/run.rs b/src/cli/run.rs index b079c69..4d556fc 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -4,7 +4,7 @@ //! and selecting projects based on flags (--all, --no-deps, --dependents). use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use crate::address::ProjectSelector; use crate::discovery::DiscoveredProject; @@ -114,6 +114,15 @@ pub fn check_reserved_target(target: &str) -> Option { /// /// Args format: `[target] [projects...] [--no-deps] [--dependents] [--all]` /// +/// Project patterns: +/// //path/to/project - exact project match (`//path/` is the same address) +/// //path/prefix/... - all projects under prefix +/// //... - all projects (same as --all) +/// ./... - all projects under current directory +/// ../web, services/api, services/* - cwd-relative path or glob +/// . - project containing the current directory +/// -//path/... - exclude from selection +/// /// Unknown `--flags` are an error. There is no bare `--` passthrough: a lone /// `--` is itself an unexpected argument. `--help` and `-h` set `help` and /// win over any argument error, so a help request always prints usage. @@ -227,7 +236,7 @@ pub fn parse_run_args(args: Vec) -> Result { } } "." => { - // Current directory - use cwd detection + // Current directory - the project containing cwd, not a path. use_cwd = true; } _ if arg.starts_with("--") => { @@ -235,14 +244,14 @@ pub fn parse_run_args(args: Vec) -> Result { } _ if arg.starts_with("-//") => { // Exclusion pattern (e.g., -//vendor/...) - exclusions.push(arg[1..].to_string()); // Strip leading "-" + exclusions.push(strip_trailing_address_slash(&arg[1..])); } _ if arg.starts_with("//") => { - // Project address or glob - projects.push(arg); + // Project address or glob. A trailing slash is the same address. + projects.push(strip_trailing_address_slash(&arg)); } - _ if arg.starts_with("./") => { - // Relative path pattern (e.g., ./...) + _ if arg.starts_with("./") || arg.starts_with("../") => { + // Relative path or glob (./..., ../web, ../...) projects.push(arg); } _ if target.is_empty() => { @@ -250,8 +259,8 @@ pub fn parse_run_args(args: Vec) -> Result { target = arg; } _ => { - // Additional positional args could be projects without // - // For now, require // prefix + // Plain relative selector (services/api, services/*, ../web without a slash form) + projects.push(arg); } } } @@ -290,6 +299,269 @@ pub fn parse_run_args(args: Vec) -> Result { }) } +/// `//services/api/` is the same address as `//services/api`. Leave `//` alone. +fn strip_trailing_address_slash(address: &str) -> String { + let mut trimmed = address.to_string(); + while trimmed.len() > 2 && trimmed.ends_with('/') { + trimmed.pop(); + } + trimmed +} + +fn unrecognised_selector(selector: &str) -> String { + format!("unrecognised project selector '{selector}'; use //path, //path/..., ./..., or .") +} + +fn outside_workspace(selector: &str, workspace_root: &Path) -> String { + format!( + "selector {selector} resolves outside the workspace {}", + workspace_root.display() + ) +} + +fn project_address(project: &DiscoveredProject) -> String { + format!("//{}", project.relative_path.display()) +} + +fn address_relative(project_addr: &str) -> String { + project_addr.trim_start_matches("//").replace('\\', "/") +} + +/// `.` and `./` name the project containing cwd, not a path to resolve. +fn is_cwd_dot(selector: &str) -> bool { + matches!(selector, "." | "./" | "./." | "././") +} + +fn logical_normalize(path: &Path) -> Option { + let mut out = std::path::PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => out.push(prefix.as_os_str()), + Component::RootDir => out.push(component.as_os_str()), + Component::CurDir => {} + Component::ParentDir => { + if !out.pop() { + return None; + } + } + Component::Normal(segment) => out.push(segment), + } + } + if out.as_os_str().is_empty() { + None + } else { + Some(out) + } +} + +/// Collapse `.`/`..` in `path_part` against `cwd`, then require the result to +/// stay inside `workspace_root`. The returned path uses `/` separators and is +/// empty at the workspace root. +fn resolve_workspace_relative( + original: &str, + path_part: &str, + cwd: &Path, + workspace_root: &Path, +) -> Result { + let joined = if path_part.is_empty() || path_part == "." { + cwd.to_path_buf() + } else { + cwd.join(path_part) + }; + let normalized = + logical_normalize(&joined).ok_or_else(|| outside_workspace(original, workspace_root))?; + let relative = normalized + .strip_prefix(workspace_root) + .map_err(|_| outside_workspace(original, workspace_root))?; + Ok(relative.to_string_lossy().replace('\\', "/")) +} + +#[derive(Clone)] +enum GlobSegment { + Literal(String), + /// Exactly one path segment. + Star, + /// Zero or more path segments. + DoubleStar, +} + +/// `*` is one segment and `**` is any depth. Any other glob metacharacter is rejected. +fn parse_glob(pattern: &str) -> Option> { + let mut segments = Vec::new(); + for segment in pattern.split('/') { + if segment.is_empty() || segment == "." { + continue; + } + if segment == ".." { + return None; + } + if segment == "*" { + segments.push(GlobSegment::Star); + } else if segment == "**" { + segments.push(GlobSegment::DoubleStar); + } else if segment.contains(['*', '?', '[']) { + return None; + } else { + segments.push(GlobSegment::Literal(segment.to_string())); + } + } + Some(segments) +} + +fn glob_matches(pattern: &[GlobSegment], path: &[&str]) -> bool { + fn rec(pattern: &[GlobSegment], path: &[&str]) -> bool { + match pattern.split_first() { + None => path.is_empty(), + Some((GlobSegment::Literal(literal), rest)) => { + path.first().is_some_and(|segment| segment == literal) && rec(rest, &path[1..]) + } + Some((GlobSegment::Star, rest)) => !path.is_empty() && rec(rest, &path[1..]), + Some((GlobSegment::DoubleStar, rest)) => { + (0..=path.len()).any(|index| rec(rest, &path[index..])) + } + } + } + rec(pattern, path) +} + +enum RelativeSelector { + Exact(String), + Recursive(String), + Glob(Vec), +} + +fn classify_relative( + selector: &str, + cwd: &Path, + workspace_root: &Path, +) -> Result { + // `../...` and `services/...` are recursive prefixes, not a directory named `...`. + if !selector.starts_with('/') { + if let Some(prefix) = selector.strip_suffix("/...") { + if prefix.contains(['*', '?', '[']) { + return Err(unrecognised_selector(selector)); + } + let relative = resolve_workspace_relative(selector, prefix, cwd, workspace_root)?; + return Ok(RelativeSelector::Recursive(relative)); + } + } + + let path_part = selector.trim_end_matches('/'); + // Collapse `.`/`..` before reading glob metacharacters so `../*` is + // `services/*`, and a pattern that leaves the workspace is an outside error. + let relative = resolve_workspace_relative(selector, path_part, cwd, workspace_root)?; + if relative.contains(['*', '?', '[']) { + let Some(glob) = parse_glob(&relative) else { + return Err(unrecognised_selector(selector)); + }; + return Ok(RelativeSelector::Glob(glob)); + } + + Ok(RelativeSelector::Exact(relative)) +} + +fn relative_matches(selector: &RelativeSelector, project_rel: &str) -> bool { + match selector { + RelativeSelector::Exact(relative) => project_rel == relative, + RelativeSelector::Recursive(relative) => { + relative.is_empty() + || project_rel == relative + || project_rel.starts_with(&format!("{relative}/")) + } + RelativeSelector::Glob(glob) => { + let path: Vec<&str> = project_rel + .split('/') + .filter(|segment| !segment.is_empty()) + .collect(); + glob_matches(glob, &path) + } + } +} + +fn address_matches(pattern: &str, project_addr: &str) -> bool { + ProjectSelector::parse(pattern) + .map(|selector| selector.matches(project_addr)) + .unwrap_or(false) +} + +enum PreparedSelector { + All, + Cwd, + Address(String), + Relative(RelativeSelector), +} + +fn prepare_selector( + selector: &str, + cwd: &Path, + workspace_root: &Path, +) -> Result { + if is_cwd_dot(selector) { + return Ok(PreparedSelector::Cwd); + } + if selector.starts_with("//") { + let address = strip_trailing_address_slash(selector); + if address == "//..." { + return Ok(PreparedSelector::All); + } + return Ok(PreparedSelector::Address(address)); + } + Ok(PreparedSelector::Relative(classify_relative( + selector, + cwd, + workspace_root, + )?)) +} + +fn selector_matches_address(prepared: &PreparedSelector, project_addr: &str) -> bool { + match prepared { + PreparedSelector::All => true, + PreparedSelector::Cwd => false, + PreparedSelector::Address(address) => address_matches(address, project_addr), + PreparedSelector::Relative(selector) => { + relative_matches(selector, &address_relative(project_addr)) + } + } +} + +/// Project containing `cwd`: the longest discovered directory that is a prefix of cwd. +/// A root project (empty path) matches only when cwd is the workspace root. +fn select_containing_project<'a>( + discovered: &'a [DiscoveredProject], + cwd: &Path, + workspace_root: &Path, +) -> Result, String> { + let no_project = || { + "No projects specified. Use --all to run on all projects, or specify project addresses." + .to_string() + }; + let Some(rel_cwd) = cwd.strip_prefix(workspace_root).ok() else { + return Err(no_project()); + }; + + let mut best_match: Option<&DiscoveredProject> = None; + let mut best_match_len = 0; + for project in discovered { + let proj_path = &project.relative_path; + let is_match = if proj_path.as_os_str().is_empty() { + rel_cwd.as_os_str().is_empty() + } else { + rel_cwd.starts_with(proj_path) + }; + if is_match { + let path_len = proj_path.as_os_str().len(); + if path_len > best_match_len || best_match.is_none() { + best_match = Some(project); + best_match_len = path_len; + } + } + } + + best_match + .map(|project| vec![project]) + .ok_or_else(no_project) +} + /// Select initial projects based on args /// /// Priority: @@ -298,11 +570,17 @@ pub fn parse_run_args(args: Vec) -> Result { /// 3. Otherwise: try to find project in cwd /// /// Glob syntax: -/// - `//path/to/project` - exact match +/// - `//path/to/project` - exact match (`//path/` is the same address) /// - `//path/prefix/...` - all projects under that path prefix /// - `//...` - all projects -/// - `./...` - all projects under current directory +/// - `./...`, `../...`, `path/...` - all projects under a cwd-relative prefix +/// - `services/api`, `../web`, `./path` - one project, resolved against cwd +/// - `*` matches one path segment; `**` matches any depth +/// - `.` - the project containing cwd /// - `-//path/...` - exclude from selection (in args.exclusions) +/// +/// A relative selector that matches nothing is an error. The cwd fallback runs +/// only when no selector was given. pub fn select_projects<'a>( args: &RunArgs, graph: &'a ProjectGraph, @@ -310,134 +588,97 @@ pub fn select_projects<'a>( cwd: &Path, workspace_root: &Path, ) -> Result, String> { - // Map addresses to discovered projects - let project_by_addr: std::collections::HashMap = discovered - .iter() - .map(|p| (format!("//{}", p.relative_path.display()), p)) - .collect(); - - // Get cwd relative to workspace for ./... pattern - let relative_cwd = cwd.strip_prefix(workspace_root).ok(); - - // Absolute patterns use the shared selector grammar; ./... remains CLI-only. - let matches_pattern = |pattern: &str, project_addr: &str| -> bool { - if pattern.starts_with("//") { - return ProjectSelector::parse(pattern) - .map(|selector| selector.matches(project_addr)) - .unwrap_or(false); - } + let project_by_addr: std::collections::HashMap = + discovered.iter().map(|p| (project_address(p), p)).collect(); - let Some(prefix) = pattern.strip_suffix("/...") else { - return false; - }; - let abs_prefix = if prefix == "." { - relative_cwd - .map(|p| format!("//{}", p.display())) - .unwrap_or_default() - } else if prefix.starts_with("./") { - relative_cwd - .map(|p| format!("//{}/{}", p.display(), &prefix[2..])) - .unwrap_or_default() - } else { - return false; - }; - - !abs_prefix.is_empty() - && (project_addr == abs_prefix || project_addr.starts_with(&format!("{abs_prefix}/"))) - }; - - // Check for //... in projects (means all) - let select_all = args.all || args.projects.iter().any(|p| p == "//..."); + let select_all = args.all + || args + .projects + .iter() + .any(|pattern| strip_trailing_address_slash(pattern) == "//..."); let mut result: Vec<&DiscoveredProject>; let mut seen = HashSet::new(); if select_all { - // Start with all projects result = discovered.iter().collect(); - for p in &result { - seen.insert(format!("//{}", p.relative_path.display())); + for project in &result { + seen.insert(project_address(project)); } - } else if !args.projects.is_empty() { - // Return explicitly specified projects + } else if args.projects.is_empty() { + // No selector: the project containing cwd. `.` sets use_cwd and leaves + // projects empty, so it takes this same path. + return select_containing_project(discovered, cwd, workspace_root); + } else { result = Vec::new(); + if args.use_cwd { + for project in select_containing_project(discovered, cwd, workspace_root)? { + if seen.insert(project_address(project)) { + result.push(project); + } + } + } for pattern in &args.projects { - if pattern == "//..." { - continue; // Already handled above + let prepared = prepare_selector(pattern, cwd, workspace_root)?; + if matches!(prepared, PreparedSelector::All) { + continue; } - - let mut found_any = false; - - for p in discovered { - let project_addr = format!("//{}", p.relative_path.display()); - - if matches_pattern(pattern, &project_addr) && seen.insert(project_addr) { - result.push(p); - found_any = true; + if matches!(prepared, PreparedSelector::Cwd) { + for project in select_containing_project(discovered, cwd, workspace_root)? { + if seen.insert(project_address(project)) { + result.push(project); + } } + continue; } - // For exact matches (non-glob), verify the project exists - if !found_any && !pattern.ends_with("/...") { - // Check if it's in the graph but just not matched - if graph.get(pattern).is_none() && !project_by_addr.contains_key(pattern) { - return Err(format!("Project not found: {pattern}")); + let mut found_any = false; + for project in discovered { + let project_addr = project_address(project); + if selector_matches_address(&prepared, &project_addr) { + found_any = true; + if seen.insert(project_addr) { + result.push(project); + } } } - if !found_any && pattern.ends_with("/...") { - return Err(format!("No projects found matching: {pattern}")); + if found_any { + continue; } - } - } else { - // Try to detect project from cwd - if let Some(rel_cwd) = relative_cwd { - let mut best_match: Option<&DiscoveredProject> = None; - let mut best_match_len = 0; - for project in discovered { - let proj_path = &project.relative_path; - // Handle root project (empty path) specially - only match if we're at root - let is_match = if proj_path.as_os_str().is_empty() { - rel_cwd.as_os_str().is_empty() - } else { - rel_cwd.starts_with(proj_path) - }; - if is_match { - let path_len = proj_path.as_os_str().len(); - if path_len > best_match_len || best_match.is_none() { - best_match = Some(project); - best_match_len = path_len; - } + if let PreparedSelector::Address(address) = &prepared { + if address.ends_with("/...") { + return Err(format!("No projects found matching: {address}")); } - } - - if let Some(project) = best_match { - return Ok(vec![project]); + if graph.get(address).is_none() && !project_by_addr.contains_key(address) { + return Err(format!("Project not found: {address}")); + } + } else { + return Err(unrecognised_selector(pattern)); } } - - return Err( - "No projects specified. Use --all to run on all projects, or specify project addresses." - .to_string(), - ); } - // Apply exclusions if !args.exclusions.is_empty() { - result.retain(|p| { - let project_addr = format!("//{}", p.relative_path.display()); - !args - .exclusions - .iter() - .any(|excl| matches_pattern(excl, &project_addr)) + result.retain(|project| { + let project_addr = project_address(project); + !args.exclusions.iter().any(|exclusion| { + let exclusion = strip_trailing_address_slash(exclusion); + if exclusion.starts_with("//") { + address_matches(&exclusion, &project_addr) + } else { + prepare_selector(&exclusion, cwd, workspace_root) + .ok() + .is_some_and(|prepared| selector_matches_address(&prepared, &project_addr)) + } + }) }); } - // Apply language filter if !args.lang.is_empty() { - result.retain(|p| p.has_any_language(&args.lang)); + result.retain(|project| project.has_any_language(&args.lang)); } Ok(result) @@ -634,6 +875,7 @@ mod tests { #[test] fn test_parse_run_args_with_dot() { + // `.` is the cwd selector (project containing cwd), not a stored path. let args = parse_ok(vec!["test".to_string(), ".".to_string()]); assert_eq!(args.target, "test"); @@ -641,6 +883,47 @@ mod tests { assert!(args.use_cwd); } + #[test] + fn test_parse_run_args_keeps_relative_selectors() { + let args = parse_ok(vec![ + "test".to_string(), + "services/api".to_string(), + "../web".to_string(), + "services/*".to_string(), + "services/**".to_string(), + "./...".to_string(), + "../...".to_string(), + "--no-deps".to_string(), + ]); + + assert_eq!(args.target, "test"); + assert_eq!( + args.projects, + vec![ + "services/api", + "../web", + "services/*", + "services/**", + "./...", + "../..." + ] + ); + assert!(args.no_deps); + assert!(!args.use_cwd); + } + + #[test] + fn test_parse_run_args_strips_trailing_address_slash() { + let args = parse_ok(vec![ + "test".to_string(), + "//services/api/".to_string(), + "-//services/web/".to_string(), + ]); + + assert_eq!(args.projects, vec!["//services/api"]); + assert_eq!(args.exclusions, vec!["//services/web"]); + } + #[test] fn test_check_reserved_target_allows_normal_targets() { assert!(check_reserved_target("test").is_none()); @@ -1410,5 +1693,125 @@ mod tests { assert_eq!(selected.len(), 2); } + + fn service_projects() -> Vec { + vec![ + make_project("api", "services/api"), + make_project("web", "services/web"), + make_project("api-v2", "services/api/v2"), + make_project("core", "libs/core"), + ] + } + + fn selected_names( + projects: &[DiscoveredProject], + selectors: &[&str], + cwd: &Path, + ) -> Result, String> { + let graph = build_graph(projects).unwrap(); + let args = RunArgs { + target: "test".to_string(), + projects: selectors + .iter() + .map(|selector| (*selector).to_string()) + .collect(), + ..Default::default() + }; + let selected = select_projects(&args, &graph, projects, cwd, Path::new("/workspace"))?; + Ok(selected + .iter() + .map(|project| project.metadata.name.clone()) + .collect()) + } + + #[test] + fn test_select_projects_relative_directory_and_globs() { + let projects = service_projects(); + let root = Path::new("/workspace"); + + let api = selected_names(&projects, &["services/api"], root).unwrap(); + assert_eq!(api, vec!["api".to_string()]); + + let star = selected_names(&projects, &["services/*"], root).unwrap(); + assert!(star.contains(&"api".to_string())); + assert!(star.contains(&"web".to_string())); + assert!(!star.contains(&"api-v2".to_string())); + assert!(!star.contains(&"core".to_string())); + + let deep = selected_names(&projects, &["services/**"], root).unwrap(); + assert!(deep.contains(&"api".to_string())); + assert!(deep.contains(&"web".to_string())); + assert!(deep.contains(&"api-v2".to_string())); + assert!(!deep.contains(&"core".to_string())); + + let under_cwd = selected_names(&projects, &["./..."], root).unwrap(); + assert_eq!(under_cwd.len(), 4); + } + + #[test] + fn test_select_projects_parent_selectors_resolve_against_cwd() { + let projects = service_projects(); + let web = Path::new("/workspace/services/web"); + + let api = selected_names(&projects, &["../api"], web).unwrap(); + assert_eq!(api, vec!["api".to_string()]); + + let under_services = selected_names(&projects, &["../..."], web).unwrap(); + assert!(under_services.contains(&"api".to_string())); + assert!(under_services.contains(&"web".to_string())); + assert!(under_services.contains(&"api-v2".to_string())); + assert!(!under_services.contains(&"core".to_string())); + + let star = selected_names(&projects, &["../*"], web).unwrap(); + assert!(star.contains(&"api".to_string())); + assert!(star.contains(&"web".to_string())); + assert!(!star.contains(&"api-v2".to_string())); + assert!(!star.contains(&"core".to_string())); + } + + #[test] + fn test_select_projects_unknown_selector_does_not_fall_back_to_cwd() { + let projects = service_projects(); + let api = Path::new("/workspace/services/api"); + let error = selected_names(&projects, &["bogus"], api).unwrap_err(); + assert_eq!( + error, + "unrecognised project selector 'bogus'; use //path, //path/..., ./..., or ." + ); + } + + #[test] + fn test_select_projects_outside_workspace_names_selector_and_root() { + let projects = service_projects(); + let error = selected_names(&projects, &[".."], Path::new("/workspace")).unwrap_err(); + assert_eq!( + error, + "selector .. resolves outside the workspace /workspace" + ); + let glob = selected_names(&projects, &["../*"], Path::new("/workspace")).unwrap_err(); + assert_eq!( + glob, + "selector ../* resolves outside the workspace /workspace" + ); + } + + #[test] + fn test_select_projects_trailing_slash_address_matches_exact_project() { + let projects = service_projects(); + let selected = + selected_names(&projects, &["//services/api/"], Path::new("/workspace")).unwrap(); + assert_eq!(selected, vec!["api".to_string()]); + } + + #[test] + fn test_select_projects_unsupported_glob_is_unrecognised() { + let projects = service_projects(); + let error = + selected_names(&projects, &["services/api*"], Path::new("/workspace")).unwrap_err(); + assert!( + error.starts_with("unrecognised project selector 'services/api*'"), + "{error}" + ); + } } } diff --git a/tests/integration.rs b/tests/integration.rs index c780dc3..445a313 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -5624,3 +5624,143 @@ testpaths = ["tests"] "a failed discovery must not print a JSON project list" ); } + +#[test] +fn relative_and_glob_selectors_select_expected_projects() { + let tmp = TempDir::new().unwrap(); + setup_workspace(&tmp); + write_package_json( + &tmp, + "services/api/package.json", + r#"{"name":"api","scripts":{"test":"echo api"}}"#, + ); + write_package_json( + &tmp, + "services/web/package.json", + r#"{"name":"web","scripts":{"test":"echo web"}}"#, + ); + write_package_json( + &tmp, + "services/api/v2/package.json", + r#"{"name":"api-v2","scripts":{"test":"echo api-v2"}}"#, + ); + write_package_json( + &tmp, + "libs/core/package.json", + r#"{"name":"core","scripts":{"test":"echo core"}}"#, + ); + + let run = |dir: &std::path::Path, args: &[&str]| { + Command::new(env!("CARGO_BIN_EXE_aster")) + .current_dir(dir) + .args(args) + .output() + .unwrap() + }; + let stderr_of = + |output: &std::process::Output| String::from_utf8_lossy(&output.stderr).to_string(); + let assert_passed = |stderr: &str, address: &str| { + assert!( + stderr.contains(&format!("PASS {address}")), + "missing PASS {address}:\n{stderr}" + ); + }; + let assert_not_passed = |stderr: &str, address: &str| { + assert!( + !stderr.contains(&format!("PASS {address}")), + "unexpected PASS {address}:\n{stderr}" + ); + }; + + let api = run(tmp.path(), &["test", "services/api", "--no-deps"]); + let api_err = stderr_of(&api); + assert!(api.status.success(), "{api_err}"); + assert_passed(&api_err, "//services/api:test"); + assert_not_passed(&api_err, "//services/web:test"); + assert_not_passed(&api_err, "//services/api/v2:test"); + + let star = run(tmp.path(), &["test", "services/*", "--no-deps"]); + let star_err = stderr_of(&star); + assert!(star.status.success(), "{star_err}"); + assert_passed(&star_err, "//services/api:test"); + assert_passed(&star_err, "//services/web:test"); + assert_not_passed(&star_err, "//services/api/v2:test"); + assert_not_passed(&star_err, "//libs/core:test"); + + let deep = run(tmp.path(), &["test", "services/**", "--no-deps"]); + let deep_err = stderr_of(&deep); + assert!(deep.status.success(), "{deep_err}"); + assert_passed(&deep_err, "//services/api:test"); + assert_passed(&deep_err, "//services/web:test"); + assert_passed(&deep_err, "//services/api/v2:test"); + assert_not_passed(&deep_err, "//libs/core:test"); + + let under_root = run(tmp.path(), &["test", "./...", "--no-deps"]); + let under_root_err = stderr_of(&under_root); + assert!(under_root.status.success(), "{under_root_err}"); + assert_passed(&under_root_err, "//services/api:test"); + assert_passed(&under_root_err, "//services/web:test"); + assert_passed(&under_root_err, "//services/api/v2:test"); + assert_passed(&under_root_err, "//libs/core:test"); + + let slashed = run(tmp.path(), &["test", "//services/api/", "--no-deps"]); + let slashed_err = stderr_of(&slashed); + assert!(slashed.status.success(), "{slashed_err}"); + assert_passed(&slashed_err, "//services/api:test"); + assert_not_passed(&slashed_err, "//services/web:test"); + + let bogus = run( + &tmp.path().join("services/api"), + &["test", "bogus", "--no-deps"], + ); + let bogus_err = stderr_of(&bogus); + assert!(!bogus.status.success(), "{bogus_err}"); + assert!( + bogus_err + .contains("unrecognised project selector 'bogus'; use //path, //path/..., ./..., or ."), + "{bogus_err}" + ); + assert_not_passed(&bogus_err, "//services/api:test"); + + let sibling = run( + &tmp.path().join("services/api"), + &["test", "../web", "--no-deps"], + ); + let sibling_err = stderr_of(&sibling); + assert!(sibling.status.success(), "{sibling_err}"); + assert_passed(&sibling_err, "//services/web:test"); + assert_not_passed(&sibling_err, "//services/api:test"); + + let from_web = run( + &tmp.path().join("services/web"), + &["test", "../api", "--no-deps"], + ); + let from_web_err = stderr_of(&from_web); + assert!(from_web.status.success(), "{from_web_err}"); + assert_passed(&from_web_err, "//services/api:test"); + assert_not_passed(&from_web_err, "//services/web:test"); + assert_not_passed(&from_web_err, "//services/api/v2:test"); + + let under_services = run( + &tmp.path().join("services/web"), + &["test", "../...", "--no-deps"], + ); + let under_services_err = stderr_of(&under_services); + assert!(under_services.status.success(), "{under_services_err}"); + assert_passed(&under_services_err, "//services/api:test"); + assert_passed(&under_services_err, "//services/web:test"); + assert_passed(&under_services_err, "//services/api/v2:test"); + assert_not_passed(&under_services_err, "//libs/core:test"); + + let outside = run(tmp.path(), &["test", "..", "--no-deps"]); + let outside_err = stderr_of(&outside); + assert!(!outside.status.success(), "{outside_err}"); + let root = tmp.path().canonicalize().unwrap(); + assert!( + outside_err.contains(&format!( + "selector .. resolves outside the workspace {}", + root.display() + )), + "{outside_err}" + ); +}