Skip to main content

cargo_detect_package/
lib.rs

1#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
2
3//! A Cargo tool to detect the package that a file belongs to, passing the package name
4//! to a subcommand.
5//!
6//! This crate provides the core logic for package detection, exposed via the [`run`] function.
7//! The binary entry point is in `main.rs`.
8
9mod detection;
10mod execution;
11mod pal;
12mod types;
13mod workspace;
14
15use detection::{DetectedPackage, detect_package};
16use execution::{execute_with_cargo_args, execute_with_env_var};
17use pal::{Filesystem, FilesystemFacade};
18pub use types::*;
19use workspace::validate_workspace_context;
20
21/// Core logic of the tool, extracted for testability.
22///
23/// This function contains all the business logic without any process-global dependencies
24/// like `std::env::args()`, making it suitable for direct testing.
25#[doc(hidden)]
26pub fn run(input: &RunInput) -> Result<RunOutcome, RunError> {
27    run_with_filesystem(input, &FilesystemFacade::target())
28}
29
30/// Internal implementation of `run` that accepts a filesystem abstraction.
31///
32/// This allows mocking filesystem operations in tests.
33fn run_with_filesystem(input: &RunInput, fs: &impl Filesystem) -> Result<RunOutcome, RunError> {
34    // Validate that we are running from within the same workspace as the target path.
35    // This also canonicalizes paths and finds the workspace root, which we reuse later.
36    let workspace_context = validate_workspace_context(&input.path, fs)
37        .map_err(|e| RunError::WorkspaceValidation(e.to_string()))?;
38
39    let detected_package = detect_package(&workspace_context, fs)
40        .map_err(|e| RunError::PackageDetection(e.to_string()))?;
41
42    // Handle outside package actions.
43    match (&detected_package, &input.outside_package) {
44        (DetectedPackage::Workspace, OutsidePackageAction::Ignore) => {
45            println!("Path is not in any package, ignoring as requested");
46            return Ok(RunOutcome::Ignored);
47        }
48        (DetectedPackage::Workspace, OutsidePackageAction::Error) => {
49            return Err(RunError::OutsidePackage);
50        }
51        (DetectedPackage::Package(name), _) => {
52            println!("Detected package: {name}");
53        }
54        (DetectedPackage::Workspace, OutsidePackageAction::Workspace) => {
55            println!("Path is not in any package, using workspace scope");
56        }
57    }
58
59    assert_early_exit_cases_handled(&detected_package, &input.outside_package);
60
61    let exit_status = match &input.via_env {
62        Some(env_var) => execute_with_env_var(
63            &workspace_context.workspace_root,
64            env_var,
65            &detected_package,
66            &input.subcommand,
67        ),
68        None => execute_with_cargo_args(
69            &workspace_context.workspace_root,
70            &detected_package,
71            &input.subcommand,
72        ),
73    }
74    .map_err(RunError::CommandExecution)?;
75
76    let subcommand_succeeded = exit_status.success();
77
78    match detected_package {
79        DetectedPackage::Package(package_name) => Ok(RunOutcome::PackageDetected {
80            package_name,
81            subcommand_succeeded,
82        }),
83        DetectedPackage::Workspace => Ok(RunOutcome::WorkspaceScope {
84            subcommand_succeeded,
85        }),
86    }
87}
88
89/// Defense-in-depth check: verifies that the match block in `run()` has handled all early-exit
90/// cases. If this assertion fails, it indicates a logic bug in the match block that should have
91/// returned early for the Ignore and Error cases.
92///
93/// This assertion can never fail with the current code structure because the match block returns
94/// early for both (Workspace, Ignore) and (Workspace, Error) cases.
95// Defense-in-depth check that can never be reached due to earlier match block logic.
96#[cfg_attr(test, mutants::skip)]
97#[cfg_attr(coverage_nightly, coverage(off))]
98fn assert_early_exit_cases_handled(
99    detected_package: &DetectedPackage,
100    outside_package: &OutsidePackageAction,
101) {
102    let is_early_exit_case = matches!(
103        (detected_package, outside_package),
104        (
105            DetectedPackage::Workspace,
106            OutsidePackageAction::Ignore | OutsidePackageAction::Error
107        )
108    );
109    assert!(
110        !is_early_exit_case,
111        "Logic error: the match block above must return early for Ignore/Error cases"
112    );
113}
114
115// Mock-based tests that do not require real filesystem access.
116// These tests duplicate key scenarios from the integration tests but use a mock filesystem,
117// enabling fine-grained control over filesystem behavior (e.g., files disappearing mid-operation).
118#[cfg(test)]
119#[cfg_attr(coverage_nightly, coverage(off))]
120mod mock_tests {
121    use std::io;
122    use std::path::{Path, PathBuf};
123
124    use super::*;
125    use crate::detection::WorkspaceContext;
126    use crate::pal::{FilesystemFacade, MockFilesystem};
127
128    /// Helper to create a mock filesystem for a simple workspace with one package.
129    ///
130    /// Structure:
131    /// ```text
132    /// /workspace/
133    ///   Cargo.toml (workspace)
134    ///   package_a/
135    ///     Cargo.toml (package: "package_a")
136    ///     src/
137    ///       lib.rs
138    ///   README.md
139    /// ```
140    fn create_simple_workspace_mock() -> MockFilesystem {
141        let mut mock = MockFilesystem::new();
142
143        // current_dir returns the workspace root.
144        mock.expect_current_dir()
145            .returning(|| Ok(PathBuf::from("/workspace")));
146
147        // Set up cargo_toml_exists expectations.
148        mock.expect_cargo_toml_exists().returning(|dir| {
149            let path_str = dir.to_string_lossy();
150            path_str == "/workspace" || path_str == "/workspace/package_a"
151        });
152
153        // Set up read_cargo_toml expectations.
154        mock.expect_read_cargo_toml().returning(|dir| {
155            let path_str = dir.to_string_lossy();
156            if path_str == "/workspace" {
157                Ok(r#"[workspace]
158members = ["package_a"]
159"#
160                .to_string())
161            } else if path_str == "/workspace/package_a" {
162                Ok(r#"[package]
163name = "package_a"
164version = "0.1.0"
165"#
166                .to_string())
167            } else {
168                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
169            }
170        });
171
172        // Set up is_file expectations.
173        mock.expect_is_file().returning(|path| {
174            let path_str = path.to_string_lossy();
175            path_str.ends_with(".rs") || path_str.ends_with(".md")
176        });
177
178        // Set up exists expectations.
179        mock.expect_exists().returning(|path| {
180            let path_str = path.to_string_lossy();
181            path_str == "/workspace"
182                || path_str == "/workspace/Cargo.toml"
183                || path_str == "/workspace/package_a"
184                || path_str == "/workspace/package_a/Cargo.toml"
185                || path_str == "/workspace/package_a/src"
186                || path_str == "/workspace/package_a/src/lib.rs"
187                || path_str == "/workspace/README.md"
188        });
189
190        // Set up canonicalize expectations - returns path as-is for virtual filesystem.
191        mock.expect_canonicalize()
192            .returning(|path| Ok(path.to_path_buf()));
193
194        mock
195    }
196
197    #[test]
198    fn mock_package_detection_in_simple_workspace() {
199        let mock = create_simple_workspace_mock();
200        let fs = FilesystemFacade::from_mock(mock);
201
202        let context =
203            validate_workspace_context(Path::new("/workspace/package_a/src/lib.rs"), &fs).unwrap();
204
205        let result = detect_package(&context, &fs).unwrap();
206
207        assert_eq!(result, DetectedPackage::Package("package_a".to_string()));
208    }
209
210    #[test]
211    fn mock_workspace_root_file_fallback() {
212        let mock = create_simple_workspace_mock();
213        let fs = FilesystemFacade::from_mock(mock);
214
215        let context = validate_workspace_context(Path::new("/workspace/README.md"), &fs).unwrap();
216
217        let result = detect_package(&context, &fs).unwrap();
218
219        assert_eq!(result, DetectedPackage::Workspace);
220    }
221
222    #[test]
223    fn mock_nonexistent_file_error() {
224        let mut mock = MockFilesystem::new();
225
226        mock.expect_current_dir()
227            .returning(|| Ok(PathBuf::from("/workspace")));
228
229        mock.expect_cargo_toml_exists()
230            .returning(|dir| dir.to_string_lossy() == "/workspace");
231
232        mock.expect_read_cargo_toml().returning(|dir| {
233            if dir.to_string_lossy() == "/workspace" {
234                Ok("[workspace]\nmembers = []\n".to_string())
235            } else {
236                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
237            }
238        });
239
240        mock.expect_exists().returning(|path| {
241            let path_str = path.to_string_lossy();
242            path_str == "/workspace" || path_str == "/workspace/Cargo.toml"
243        });
244
245        mock.expect_canonicalize().returning(|path| {
246            let path_str = path.to_string_lossy();
247            if path_str.contains("nonexistent") {
248                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
249            } else {
250                Ok(path.to_path_buf())
251            }
252        });
253
254        let fs = FilesystemFacade::from_mock(mock);
255
256        let result =
257            validate_workspace_context(Path::new("/workspace/package_a/src/nonexistent.rs"), &fs);
258
259        assert!(result.is_err());
260        assert!(result.unwrap_err().to_string().contains("does not exist"));
261    }
262
263    #[test]
264    fn mock_file_disappears_during_package_detection() {
265        // This test demonstrates the power of mocking - we can simulate a file disappearing
266        // between the exists check and the read operation.
267        use std::sync::Arc;
268        use std::sync::atomic::{AtomicBool, Ordering};
269
270        let file_exists = Arc::new(AtomicBool::new(true));
271        let file_exists_clone = Arc::clone(&file_exists);
272
273        let mut mock = MockFilesystem::new();
274
275        mock.expect_current_dir()
276            .returning(|| Ok(PathBuf::from("/workspace")));
277
278        mock.expect_cargo_toml_exists().returning(move |dir| {
279            let path_str = dir.to_string_lossy();
280            if path_str == "/workspace/package_a" {
281                // First call returns true, then we "delete" the file.
282                let exists = file_exists_clone.load(Ordering::SeqCst);
283                file_exists_clone.store(false, Ordering::SeqCst);
284                exists
285            } else {
286                path_str == "/workspace"
287            }
288        });
289
290        mock.expect_read_cargo_toml().returning(|dir| {
291            let path_str = dir.to_string_lossy();
292            if path_str == "/workspace" {
293                Ok("[workspace]\nmembers = [\"package_a\"]\n".to_string())
294            } else {
295                // File was "deleted" - return error.
296                Err(io::Error::new(io::ErrorKind::NotFound, "file was deleted"))
297            }
298        });
299
300        mock.expect_is_file()
301            .returning(|path| path.to_string_lossy().ends_with(".rs"));
302
303        mock.expect_exists().returning(|_| true);
304
305        mock.expect_canonicalize()
306            .returning(|path| Ok(path.to_path_buf()));
307
308        let fs = FilesystemFacade::from_mock(mock);
309
310        // Create context directly to skip validation (which would also call the mock).
311        let context = WorkspaceContext {
312            absolute_target_path: PathBuf::from("/workspace/package_a/src/lib.rs"),
313            workspace_root: PathBuf::from("/workspace"),
314        };
315
316        // The file exists when cargo_toml_exists is called, but disappears when we try to read it.
317        let result = detect_package(&context, &fs);
318
319        assert!(result.is_err());
320        assert!(result.unwrap_err().to_string().contains("deleted"));
321    }
322
323    #[test]
324    fn mock_invalid_toml_in_package() {
325        let mut mock = MockFilesystem::new();
326
327        mock.expect_current_dir()
328            .returning(|| Ok(PathBuf::from("/workspace")));
329
330        mock.expect_cargo_toml_exists().returning(|dir| {
331            let path_str = dir.to_string_lossy();
332            path_str == "/workspace" || path_str == "/workspace/bad_package"
333        });
334
335        mock.expect_read_cargo_toml().returning(|dir| {
336            let path_str = dir.to_string_lossy();
337            if path_str == "/workspace" {
338                Ok("[workspace]\nmembers = [\"bad_package\"]\n".to_string())
339            } else if path_str == "/workspace/bad_package" {
340                // Return malformed TOML.
341                Ok("[package\nname = \"bad\"\n".to_string())
342            } else {
343                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
344            }
345        });
346
347        mock.expect_is_file()
348            .returning(|path| path.to_string_lossy().ends_with(".rs"));
349
350        mock.expect_exists().returning(|_| true);
351
352        mock.expect_canonicalize()
353            .returning(|path| Ok(path.to_path_buf()));
354
355        let fs = FilesystemFacade::from_mock(mock);
356
357        let context = WorkspaceContext {
358            absolute_target_path: PathBuf::from("/workspace/bad_package/src/lib.rs"),
359            workspace_root: PathBuf::from("/workspace"),
360        };
361
362        let result = detect_package(&context, &fs);
363
364        assert!(result.is_err());
365        let error_msg = result.unwrap_err().to_string();
366        assert!(
367            error_msg.contains("TOML") || error_msg.contains("parse"),
368            "Expected TOML parse error, got: {error_msg}"
369        );
370    }
371
372    #[test]
373    fn mock_package_without_name_field() {
374        let mut mock = MockFilesystem::new();
375
376        mock.expect_current_dir()
377            .returning(|| Ok(PathBuf::from("/workspace")));
378
379        mock.expect_cargo_toml_exists().returning(|dir| {
380            let path_str = dir.to_string_lossy();
381            path_str == "/workspace" || path_str == "/workspace/nameless"
382        });
383
384        mock.expect_read_cargo_toml().returning(|dir| {
385            let path_str = dir.to_string_lossy();
386            if path_str == "/workspace" {
387                Ok("[workspace]\nmembers = [\"nameless\"]\n".to_string())
388            } else if path_str == "/workspace/nameless" {
389                // Valid TOML but missing package name.
390                Ok("[package]\nversion = \"0.1.0\"\n".to_string())
391            } else {
392                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
393            }
394        });
395
396        mock.expect_is_file()
397            .returning(|path| path.to_string_lossy().ends_with(".rs"));
398
399        mock.expect_exists().returning(|_| true);
400
401        mock.expect_canonicalize()
402            .returning(|path| Ok(path.to_path_buf()));
403
404        let fs = FilesystemFacade::from_mock(mock);
405
406        let context = WorkspaceContext {
407            absolute_target_path: PathBuf::from("/workspace/nameless/src/lib.rs"),
408            workspace_root: PathBuf::from("/workspace"),
409        };
410
411        let result = detect_package(&context, &fs);
412
413        assert!(result.is_err());
414        assert!(
415            result
416                .unwrap_err()
417                .to_string()
418                .contains("Could not find package name")
419        );
420    }
421
422    #[test]
423    fn mock_nested_package_detection() {
424        // Test that we find the nearest package, not the workspace root.
425        let mut mock = MockFilesystem::new();
426
427        mock.expect_current_dir()
428            .returning(|| Ok(PathBuf::from("/workspace")));
429
430        mock.expect_cargo_toml_exists().returning(|dir| {
431            let path_str = dir.to_string_lossy();
432            path_str == "/workspace"
433                || path_str == "/workspace/crates/inner"
434                || path_str == "/workspace/crates"
435        });
436
437        mock.expect_read_cargo_toml().returning(|dir| {
438            let path_str = dir.to_string_lossy();
439            if path_str == "/workspace" {
440                Ok("[workspace]\nmembers = [\"crates/*\"]\n".to_string())
441            } else if path_str == "/workspace/crates/inner" {
442                Ok("[package]\nname = \"inner-package\"\nversion = \"0.1.0\"\n".to_string())
443            } else {
444                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
445            }
446        });
447
448        mock.expect_is_file()
449            .returning(|path| path.to_string_lossy().ends_with(".rs"));
450
451        mock.expect_exists().returning(|_| true);
452
453        mock.expect_canonicalize()
454            .returning(|path| Ok(path.to_path_buf()));
455
456        let fs = FilesystemFacade::from_mock(mock);
457
458        let context = WorkspaceContext {
459            absolute_target_path: PathBuf::from("/workspace/crates/inner/src/lib.rs"),
460            workspace_root: PathBuf::from("/workspace"),
461        };
462
463        let result = detect_package(&context, &fs).unwrap();
464
465        assert_eq!(
466            result,
467            DetectedPackage::Package("inner-package".to_string())
468        );
469    }
470
471    #[test]
472    fn mock_directory_target_instead_of_file() {
473        let mock = create_simple_workspace_mock();
474        let fs = FilesystemFacade::from_mock(mock);
475
476        // Target is a directory, not a file.
477        let context = WorkspaceContext {
478            absolute_target_path: PathBuf::from("/workspace/package_a/src"),
479            workspace_root: PathBuf::from("/workspace"),
480        };
481
482        let result = detect_package(&context, &fs).unwrap();
483
484        assert_eq!(result, DetectedPackage::Package("package_a".to_string()));
485    }
486
487    #[test]
488    fn mock_current_dir_outside_workspace() {
489        let mut mock = MockFilesystem::new();
490
491        mock.expect_current_dir()
492            .returning(|| Ok(PathBuf::from("/some/random/path")));
493
494        mock.expect_cargo_toml_exists().returning(|_| false);
495
496        let fs = FilesystemFacade::from_mock(mock);
497
498        let result = validate_workspace_context(Path::new("file.rs"), &fs);
499
500        assert!(result.is_err());
501        assert!(
502            result
503                .unwrap_err()
504                .to_string()
505                .contains("not within a Cargo workspace")
506        );
507    }
508
509    #[test]
510    fn mock_current_dir_fails() {
511        let mut mock = MockFilesystem::new();
512
513        mock.expect_current_dir().returning(|| {
514            Err(io::Error::new(
515                io::ErrorKind::PermissionDenied,
516                "access denied",
517            ))
518        });
519
520        let fs = FilesystemFacade::from_mock(mock);
521
522        let result = validate_workspace_context(Path::new("file.rs"), &fs);
523
524        result.unwrap_err();
525    }
526
527    #[test]
528    fn mock_detect_package_reaches_filesystem_root() {
529        // This tests the case where try_get_parent() returns None because we have walked
530        // up to the filesystem root. We simulate this by having a workspace at the root
531        // and a target file directly in the root - when we try to get parent of root, we get None.
532        let mut mock = MockFilesystem::new();
533
534        mock.expect_is_file()
535            .returning(|path| path.to_string_lossy() == "/file.rs");
536
537        // No Cargo.toml exists anywhere except at root (which is the workspace root).
538        mock.expect_cargo_toml_exists()
539            .returning(|dir| dir.to_string_lossy() == "/");
540
541        mock.expect_read_cargo_toml().returning(|dir| {
542            if dir.to_string_lossy() == "/" {
543                Ok("[workspace]\nmembers = []\n".to_string())
544            } else {
545                Err(io::Error::new(io::ErrorKind::NotFound, "not found"))
546            }
547        });
548
549        let fs = FilesystemFacade::from_mock(mock);
550
551        // Create a context where the file is at root level and workspace is root.
552        // The parent of "/" is None, so try_get_parent will return None.
553        let context = WorkspaceContext {
554            absolute_target_path: PathBuf::from("/file.rs"),
555            workspace_root: PathBuf::from("/"),
556        };
557
558        // Since the file is at root and there is no package-level Cargo.toml (only workspace),
559        // and we will hit None from try_get_parent when trying to go above root,
560        // detect_package should return Workspace.
561        let result = detect_package(&context, &fs).unwrap();
562
563        assert_eq!(result, DetectedPackage::Workspace);
564    }
565}