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