diff --git a/crates/cargo-wdk/src/actions/build/mod.rs b/crates/cargo-wdk/src/actions/build/mod.rs index 18e23484e..77e601e9a 100644 --- a/crates/cargo-wdk/src/actions/build/mod.rs +++ b/crates/cargo-wdk/src/actions/build/mod.rs @@ -197,8 +197,26 @@ impl<'a> BuildAction<'a> { return self.run_from_workspace_root(&self.working_dir); } - // Emulated workspaces support let dirs = self.fs.read_dir_entries(&self.working_dir)?; + + if let Some(workspace_root) = super::find_workspace_root( + self.metadata, + self.fs, + &self.working_dir, + &dirs, + self.locked, + self.features, + ) { + debug!( + "Working directory {} lies inside the workspace rooted at {}; running build from \ + workspace root", + self.working_dir.display(), + workspace_root.display() + ); + return self.run_from_workspace_root(&workspace_root); + } + + // Emulated workspaces support debug!( "Checking for valid Rust projects in the working directory: {}", self.working_dir.display() diff --git a/crates/cargo-wdk/src/actions/clean/mod.rs b/crates/cargo-wdk/src/actions/clean/mod.rs index 557690240..9ebd64ba9 100644 --- a/crates/cargo-wdk/src/actions/clean/mod.rs +++ b/crates/cargo-wdk/src/actions/clean/mod.rs @@ -7,12 +7,13 @@ mod error; use std::path::{Path, PathBuf, absolute}; use anyhow::Result; +use clap_cargo::Features; use error::CleanActionError; use mockall_double::double; use tracing::{debug, error as err, info}; #[double] -use crate::providers::{exec::CommandExec, fs::Fs}; +use crate::providers::{exec::CommandExec, fs::Fs, metadata::Metadata}; use crate::trace; /// Action that removes build artifacts produced by the `build` command for a @@ -24,6 +25,7 @@ pub struct CleanAction<'a> { // Injected deps command_exec: &'a CommandExec, fs: &'a Fs, + metadata: &'a Metadata, } impl<'a> CleanAction<'a> { @@ -34,6 +36,7 @@ impl<'a> CleanAction<'a> { /// * `verbosity_level` - The verbosity level for logging /// * `command_exec` - The command execution provider instance /// * `fs` - The file system provider instance + /// * `metadata` - The metadata provider instance /// /// # Returns /// * `Result` - A result containing either a new instance of @@ -47,6 +50,7 @@ impl<'a> CleanAction<'a> { verbosity_level: clap_verbosity_flag::Verbosity, command_exec: &'a CommandExec, fs: &'a Fs, + metadata: &'a Metadata, ) -> Result { anyhow::ensure!( !working_dir.as_os_str().is_empty(), @@ -57,6 +61,7 @@ impl<'a> CleanAction<'a> { verbosity_level, command_exec, fs, + metadata, }) } @@ -95,8 +100,26 @@ impl<'a> CleanAction<'a> { return self.run_cargo_clean(&self.working_dir); } - // Emulated workspaces support let dirs = self.fs.read_dir_entries(&self.working_dir)?; + + if let Some(workspace_root) = super::find_workspace_root( + self.metadata, + self.fs, + &self.working_dir, + &dirs, + false, + &Features::default(), + ) { + debug!( + "Working directory {} lies inside the workspace rooted at {}; running cargo clean \ + from workspace root", + self.working_dir.display(), + workspace_root.display() + ); + return self.run_cargo_clean(&workspace_root); + } + + // Emulated workspaces support debug!( "Checking for valid Rust projects in the working directory: {}", self.working_dir.display() @@ -186,7 +209,7 @@ mod tests { fs::DirEntryInfo, }; #[double] - use crate::providers::{exec::CommandExec, fs::Fs}; + use crate::providers::{exec::CommandExec, fs::Fs, metadata::Metadata}; fn ok_output() -> Output { Output { @@ -241,10 +264,33 @@ mod tests { }); } - fn run_action(cwd: &Path, fs: &Fs, exec: &CommandExec) -> Result<(), CleanActionError> { - CleanAction::new(cwd, clap_verbosity_flag::Verbosity::default(), exec, fs) - .expect("CleanAction::new should succeed") - .run() + fn metadata_not_in_workspace() -> Metadata { + let mut metadata = Metadata::default(); + metadata + .expect_get_cargo_metadata_at_path() + .returning(|_, _, _| { + Err(cargo_metadata::Error::CargoMetadata { + stderr: "not a workspace".to_string(), + }) + }); + metadata + } + + fn run_action( + cwd: &Path, + fs: &Fs, + exec: &CommandExec, + metadata: &Metadata, + ) -> Result<(), CleanActionError> { + CleanAction::new( + cwd, + clap_verbosity_flag::Verbosity::default(), + exec, + fs, + metadata, + ) + .expect("CleanAction::new should succeed") + .run() } #[test] @@ -252,8 +298,16 @@ mod tests { let cwd = PathBuf::from("C:\\tmp"); let fs = Fs::default(); let exec = CommandExec::default(); + let metadata = Metadata::default(); assert!( - CleanAction::new(&cwd, clap_verbosity_flag::Verbosity::default(), &exec, &fs,).is_ok() + CleanAction::new( + &cwd, + clap_verbosity_flag::Verbosity::default(), + &exec, + &fs, + &metadata, + ) + .is_ok() ); } @@ -262,9 +316,16 @@ mod tests { let cwd = PathBuf::from(""); let fs = Fs::default(); let exec = CommandExec::default(); - let err = CleanAction::new(&cwd, clap_verbosity_flag::Verbosity::default(), &exec, &fs) - .err() - .expect("CleanAction::new should fail for empty working_dir"); + let metadata = Metadata::default(); + let err = CleanAction::new( + &cwd, + clap_verbosity_flag::Verbosity::default(), + &exec, + &fs, + &metadata, + ) + .err() + .expect("CleanAction::new should fail for empty working_dir"); assert_eq!(err.to_string(), "working_dir must not be empty"); } @@ -275,9 +336,10 @@ mod tests { let cwd = PathBuf::from("C:\\tmp"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = Metadata::default(); mock_cargo_toml(&mut fs, &cwd, true); mock_cargo_clean(&mut exec, &cwd, true); - assert!(run_action(&cwd, &fs, &exec).is_ok()); + assert!(run_action(&cwd, &fs, &exec, &metadata).is_ok()); } #[test] @@ -285,10 +347,11 @@ mod tests { let cwd = PathBuf::from("C:\\tmp"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = Metadata::default(); mock_cargo_toml(&mut fs, &cwd, true); mock_cargo_clean(&mut exec, &cwd, false); assert!(matches!( - run_action(&cwd, &fs, &exec), + run_action(&cwd, &fs, &exec, &metadata), Err(CleanActionError::CargoClean(_)) )); } @@ -300,10 +363,11 @@ mod tests { let cwd = PathBuf::from("C:\\tmp"); let mut fs = Fs::default(); let exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[]); assert!(matches!( - run_action(&cwd, &fs, &exec), + run_action(&cwd, &fs, &exec, &metadata), Err(CleanActionError::NoValidRustProjectsInTheDirectory(_)) )); } @@ -314,11 +378,12 @@ mod tests { let pkg_a = cwd.join("pkg-a"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[("pkg-a", true)]); mock_cargo_toml(&mut fs, &pkg_a, true); mock_cargo_clean(&mut exec, &pkg_a, true); - assert!(run_action(&cwd, &fs, &exec).is_ok()); + assert!(run_action(&cwd, &fs, &exec, &metadata).is_ok()); } #[test] @@ -328,13 +393,14 @@ mod tests { let pkg_b = cwd.join("pkg-b"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[("pkg-a", true), ("pkg-b", true)]); mock_cargo_toml(&mut fs, &pkg_a, true); mock_cargo_toml(&mut fs, &pkg_b, true); mock_cargo_clean(&mut exec, &pkg_a, true); mock_cargo_clean(&mut exec, &pkg_b, true); - assert!(run_action(&cwd, &fs, &exec).is_ok()); + assert!(run_action(&cwd, &fs, &exec, &metadata).is_ok()); } #[test] @@ -343,12 +409,13 @@ mod tests { let pkg_a = cwd.join("pkg-a"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); // README.md (is_dir=false) is filtered out before any Cargo.toml probe. mock_read_dir(&mut fs, &cwd, &[("README.md", false), ("pkg-a", true)]); mock_cargo_toml(&mut fs, &pkg_a, true); mock_cargo_clean(&mut exec, &pkg_a, true); - assert!(run_action(&cwd, &fs, &exec).is_ok()); + assert!(run_action(&cwd, &fs, &exec, &metadata).is_ok()); } #[test] @@ -358,12 +425,13 @@ mod tests { let pkg_a = cwd.join("pkg-a"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[("docs", true), ("pkg-a", true)]); mock_cargo_toml(&mut fs, &docs, false); mock_cargo_toml(&mut fs, &pkg_a, true); mock_cargo_clean(&mut exec, &pkg_a, true); - assert!(run_action(&cwd, &fs, &exec).is_ok()); + assert!(run_action(&cwd, &fs, &exec, &metadata).is_ok()); } #[test] @@ -373,12 +441,13 @@ mod tests { let scripts = cwd.join("scripts"); let mut fs = Fs::default(); let exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[("docs", true), ("scripts", true)]); mock_cargo_toml(&mut fs, &docs, false); mock_cargo_toml(&mut fs, &scripts, false); assert!(matches!( - run_action(&cwd, &fs, &exec), + run_action(&cwd, &fs, &exec, &metadata), Err(CleanActionError::NoValidRustProjectsInTheDirectory(_)) )); } @@ -390,6 +459,7 @@ mod tests { let pkg_bad = cwd.join("pkg-bad"); let mut fs = Fs::default(); let mut exec = CommandExec::default(); + let metadata = metadata_not_in_workspace(); mock_cargo_toml(&mut fs, &cwd, false); mock_read_dir(&mut fs, &cwd, &[("pkg-ok", true), ("pkg-bad", true)]); mock_cargo_toml(&mut fs, &pkg_ok, true); @@ -397,7 +467,7 @@ mod tests { mock_cargo_clean(&mut exec, &pkg_ok, true); mock_cargo_clean(&mut exec, &pkg_bad, false); assert!(matches!( - run_action(&cwd, &fs, &exec), + run_action(&cwd, &fs, &exec, &metadata), Err(CleanActionError::OneOrMoreRustProjectsFailedToClean(_)) )); } @@ -407,6 +477,9 @@ mod tests { let cwd = PathBuf::from("C:\\tmp"); let mut fs = Fs::default(); let exec = CommandExec::default(); + // `read_dir_entries` is attempted before the metadata probe, so no + // metadata expectation is needed here. + let metadata = Metadata::default(); mock_cargo_toml(&mut fs, &cwd, false); let cwd_clone = cwd.clone(); fs.expect_read_dir_entries().returning(move |_| { @@ -416,7 +489,7 @@ mod tests { )) }); assert!(matches!( - run_action(&cwd, &fs, &exec), + run_action(&cwd, &fs, &exec, &metadata), Err(CleanActionError::FileIo(_)) )); } diff --git a/crates/cargo-wdk/src/actions/mod.rs b/crates/cargo-wdk/src/actions/mod.rs index bc7c7788d..f9ac27c15 100644 --- a/crates/cargo-wdk/src/actions/mod.rs +++ b/crates/cargo-wdk/src/actions/mod.rs @@ -10,3 +10,228 @@ pub mod build; pub mod clean; pub mod new; + +use std::path::{Path, PathBuf, absolute}; + +use clap_cargo::Features; +use mockall_double::double; + +use crate::providers::fs::DirEntryInfo; +#[double] +use crate::providers::{fs::Fs, metadata::Metadata}; + +/// Resolves the root of the workspace for a working directory that has no +/// `Cargo.toml` of its own. +fn find_workspace_root( + metadata: &Metadata, + fs: &Fs, + working_dir: &Path, + dirs: &[DirEntryInfo], + locked: bool, + features: &Features, +) -> Option { + let working_dir_trimmed: PathBuf = working_dir + .to_string_lossy() + .trim_start_matches(r"\\?\") + .into(); + let other_options = if locked { + vec!["--locked".to_string()] + } else { + Vec::new() + }; + let cargo_metadata = metadata + .get_cargo_metadata_at_path(&working_dir_trimmed, other_options, features) + .ok()?; + + let member_dirs: Vec = cargo_metadata + .workspace_packages() + .iter() + .filter_map(|p| { + p.manifest_path + .parent() + .and_then(|path| absolute(path.as_std_path()).ok()) + }) + .collect(); + + let is_emulated_workspace = dirs.iter().any(|entry| { + entry.is_dir + && fs.exists(&entry.path.join("Cargo.toml")) + && absolute(&entry.path).is_ok_and(|child_dir| { + !member_dirs + .iter() + .any(|member| member.starts_with(&child_dir)) + }) + }); + if is_emulated_workspace { + return None; + } + + absolute(cargo_metadata.workspace_root.as_std_path()).ok() +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use clap_cargo::Features; + use mockall::predicate::eq; + use mockall_double::double; + + use super::find_workspace_root; + use crate::providers::fs::DirEntryInfo; + #[double] + use crate::providers::{fs::Fs, metadata::Metadata}; + + fn dir_entry(path: PathBuf, is_dir: bool) -> DirEntryInfo { + DirEntryInfo { path, is_dir } + } + + /// A `Metadata` mock that reports the directory is not inside any + /// workspace. + fn metadata_not_in_workspace() -> Metadata { + let mut metadata = Metadata::default(); + metadata + .expect_get_cargo_metadata_at_path() + .returning(|_, _, _| { + Err(cargo_metadata::Error::CargoMetadata { + stderr: "not a workspace".to_string(), + }) + }); + metadata + } + + /// A `Metadata` mock that reports a single workspace member at + /// `member_dir`, rooted at `workspace_root`. + fn metadata_owning_dir(workspace_root: &Path, member_dir: &Path) -> Metadata { + let member_fwd = member_dir.to_string_lossy().replace('\\', "/"); + let member_fwd = member_fwd.trim_start_matches("//?/").to_string(); + let id = format!("path+file:///{member_fwd}#pkg@0.1.0"); + let json = serde_json::json!({ + "target_directory": workspace_root.join("target").to_string_lossy(), + "workspace_root": workspace_root.to_string_lossy(), + "packages": [{ + "name": "pkg", + "version": "0.1.0", + "id": id, + "dependencies": [], + "targets": [{ + "kind": ["lib"], + "crate_types": ["lib"], + "name": "pkg", + "src_path": member_dir.join("src").join("lib.rs").to_string_lossy(), + "edition": "2021", + "doc": true, + "doctest": false, + "test": true + }], + "features": {}, + "manifest_path": member_dir.join("Cargo.toml").to_string_lossy(), + "authors": [], + "categories": [], + "keywords": [], + "edition": "2021", + "metadata": null + }], + "workspace_members": [id], + "metadata": null, + "version": 1 + }); + let parsed: cargo_metadata::Metadata = + serde_json::from_value(json).expect("valid cargo metadata"); + let mut metadata = Metadata::default(); + metadata + .expect_get_cargo_metadata_at_path() + .returning(move |_, _, _| Ok(parsed.clone())); + metadata + } + + mod find_workspace_root { + use super::*; + + #[test] + fn returns_none_when_not_in_a_workspace() { + // A loose directory not inside any Cargo workspace. + let metadata = metadata_not_in_workspace(); + let fs = Fs::default(); + let working_dir = PathBuf::from("C:\\tmp\\loose"); + assert_eq!( + find_workspace_root( + &metadata, + &fs, + &working_dir, + &[], + false, + &Features::default() + ), + None + ); + } + + #[test] + fn returns_workspace_root_from_intermediate_subdirectory() { + // An intermediate directory that is an ancestor of a workspace member. + let workspace_root = PathBuf::from("C:\\tmp\\ws"); + let group_dir = workspace_root.join("group"); + let member_dir = group_dir.join("pkg"); + let metadata = metadata_owning_dir(&workspace_root, &member_dir); + let mut fs = Fs::default(); + fs.expect_exists() + .with(eq(member_dir.join("Cargo.toml"))) + .returning(|_| true); + let dirs = [dir_entry(member_dir, true)]; + assert_eq!( + find_workspace_root( + &metadata, + &fs, + &group_dir, + &dirs, + false, + &Features::default() + ), + Some(workspace_root) + ); + } + + #[test] + fn returns_workspace_root_from_dir_without_child_projects() { + // A directory inside the workspace with no child projects (e.g. `docs/` or a + // member's `src/`). + let workspace_root = PathBuf::from("C:\\tmp\\ws"); + let docs_dir = workspace_root.join("docs"); + let member_dir = workspace_root.join("pkg"); + let metadata = metadata_owning_dir(&workspace_root, &member_dir); + let fs = Fs::default(); + assert_eq!( + find_workspace_root(&metadata, &fs, &docs_dir, &[], false, &Features::default()), + Some(workspace_root) + ); + } + + #[test] + fn returns_none_for_emulated_workspace_with_non_member_children() { + // An emulated-workspace directory whose children are independent projects + // excluded from the workspace. + let workspace_root = PathBuf::from("C:\\tmp\\ws"); + let emulated_dir = workspace_root.join("examples"); + let member_dir = workspace_root.join("crates").join("pkg"); + let child = emulated_dir.join("proj-a"); + let metadata = metadata_owning_dir(&workspace_root, &member_dir); + let mut fs = Fs::default(); + fs.expect_exists() + .with(eq(child.join("Cargo.toml"))) + .returning(|_| true); + let dirs = [dir_entry(child, true)]; + assert_eq!( + find_workspace_root( + &metadata, + &fs, + &emulated_dir, + &dirs, + false, + &Features::default() + ), + None + ); + } + } +} diff --git a/crates/cargo-wdk/src/cli.rs b/crates/cargo-wdk/src/cli.rs index 030960089..dd23af57f 100644 --- a/crates/cargo-wdk/src/cli.rs +++ b/crates/cargo-wdk/src/cli.rs @@ -341,7 +341,8 @@ impl Cli { .run()?; } Subcmd::Clean => { - CleanAction::new(Path::new("."), self.verbose, &command_exec, &fs)?.run()?; + CleanAction::new(Path::new("."), self.verbose, &command_exec, &fs, &metadata)? + .run()?; } } Ok(())