1#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-unused-deps/logo.png")]
6#![doc(
7 html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-unused-deps/favicon.ico"
8)]
9#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
126#![cfg_attr(docsrs, feature(doc_cfg))]
127
128mod detect;
129mod doctests;
130mod evidence;
131mod fix;
132mod verdict;
133
134use std::collections::BTreeSet;
135use std::ffi::OsString;
136use std::fs;
137use std::io::Write;
138use std::path::{Path, PathBuf};
139use std::process::ExitCode;
140
141use anyhow::{Context, Result, bail, ensure};
142use cargo_metadata::MetadataCommand;
143use clap::builder::Styles;
144use clap::builder::styling::{AnsiColor, Effects};
145use clap::{Parser, Subcommand};
146use tempfile::NamedTempFile;
147
148use crate::detect::{Catalog, ManifestInput, Section, WorkspaceCatalog};
149use crate::fix::Carry;
150use crate::verdict::Verdict;
151
152const CLAP_STYLES: Styles = Styles::styled()
155 .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
156 .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
157 .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
158 .placeholder(AnsiColor::Cyan.on_default());
159
160#[derive(Parser, Debug)]
162#[command(bin_name = "cargo", version, about, author)]
163#[command(styles = CLAP_STYLES)]
164struct Cli {
165 #[command(subcommand)]
166 command: Commands,
167}
168
169#[derive(Subcommand, Debug)]
175enum Commands {
176 #[command(version, display_name = "cargo-unused-deps")]
179 UnusedDeps {
180 #[arg(long, default_value = "Cargo.toml", value_name = "PATH")]
182 manifest_path: PathBuf,
183
184 #[arg(short = 'p', long = "package", value_name = "SPEC")]
186 packages: Vec<String>,
187
188 #[arg(long, conflicts_with = "packages")]
190 workspace: bool,
191
192 #[arg(long, value_name = "SPEC", requires = "workspace")]
194 exclude: Vec<String>,
195
196 #[arg(long = "check", value_name = "NAME", value_enum)]
198 checks: Vec<Check>,
199
200 #[arg(long)]
202 fix: bool,
203
204 #[arg(long)]
206 require_workspace: bool,
207 },
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
212enum Check {
213 Unused,
215
216 Misplaced,
218}
219
220impl Check {
221 fn wanted(self, selected: &[Self]) -> bool {
223 selected.is_empty() || selected.contains(&self)
224 }
225}
226
227#[cfg_attr(coverage_nightly, coverage(off))]
241pub fn dispatch(args: &[OsString]) -> Result<ExitCode> {
242 if std::env::var_os(evidence::WRAPPER_VAR).is_some() {
243 return evidence::wrapper(&args[1..]);
244 }
245 if std::env::var_os(doctests::RUSTDOC_WRAPPER_VAR).is_some() {
246 return doctests::rustdoc_wrapper(&args[1..]);
247 }
248 if let Some(capture) = std::env::var_os(doctests::CAPTURE_VAR) {
249 let subcommand = args.get(1).map(OsString::as_os_str);
250 if subcommand != Some(SUBCOMMAND.as_ref()) {
251 return doctests::shim(&args[1..], Path::new(&capture));
252 }
253 }
254
255 run()
256}
257
258const SUBCOMMAND: &str = "unused-deps";
260
261fn run() -> Result<ExitCode> {
278 let cli = Cli::parse();
279 let Commands::UnusedDeps {
280 manifest_path,
281 packages,
282 workspace,
283 exclude,
284 checks,
285 fix,
286 require_workspace,
287 } = cli.command;
288 let manifest_path = workspace_manifest_of(&manifest_path)?;
289
290 let package_context = if package_checks_selected(&packages, workspace) {
291 let selection = PackageSelection {
292 packages,
293 workspace,
294 exclude,
295 };
296 let workspace = workspace_of(&manifest_path)?;
297 let selected = selection.resolve(&workspace.packages)?;
298 Some((selection.flags(), workspace, selected))
299 } else {
300 None
301 };
302
303 let mut failed = catalog_check(&manifest_path, fix, require_workspace)? != ExitCode::SUCCESS;
306
307 if let Some((selection_flags, workspace, selected)) = package_context {
308 failed |= source_checks(&manifest_path, &selection_flags, &workspace, &selected, &checks)?;
309 }
310
311 Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS })
312}
313
314#[mutants::skip]
320fn package_checks_selected(packages: &[String], workspace: bool) -> bool {
321 !packages.is_empty() || workspace
322}
323
324fn workspace_manifest_of(manifest_path: &Path) -> Result<PathBuf> {
326 let manifest = detect::read_manifest(manifest_path)?;
327 if matches!(detect::catalog(&manifest)?, Catalog::Workspace(_)) {
328 return Ok(manifest_path.to_path_buf());
329 }
330
331 let supplied = manifest_path
332 .canonicalize()
333 .context(format!("failed to resolve {}", manifest_path.display()))?;
334 let package_dir = supplied
335 .parent()
336 .expect("a canonical manifest file path always has a parent directory");
337
338 for ancestor in package_dir.ancestors().skip(1) {
339 let candidate = ancestor.join("Cargo.toml");
340 if !candidate.is_file() {
341 continue;
342 }
343 let document = detect::read_manifest(&candidate)?;
344 if !matches!(detect::catalog(&document)?, Catalog::Workspace(_)) {
345 continue;
346 }
347 if members_of(&candidate)?.iter().any(|member| member == &supplied) {
348 return Ok(candidate);
349 }
350 }
351
352 Ok(manifest_path.to_path_buf())
353}
354
355struct PackageSelection {
357 packages: Vec<String>,
359 workspace: bool,
361 exclude: Vec<String>,
363}
364
365impl PackageSelection {
366 fn flags(&self) -> Vec<OsString> {
368 let mut flags = Vec::new();
369 for package in &self.packages {
370 flags.push(OsString::from("--package"));
371 flags.push(OsString::from(package));
372 }
373 if self.workspace {
374 flags.push(OsString::from("--workspace"));
375 }
376 for excluded in &self.exclude {
377 flags.push(OsString::from("--exclude"));
378 flags.push(OsString::from(excluded));
379 }
380 flags
381 }
382
383 fn resolve(&self, packages: &[verdict::Package]) -> Result<BTreeSet<PathBuf>> {
385 let mut selected = if self.workspace {
386 packages.iter().map(|package| package.manifest_path.clone()).collect()
387 } else {
388 resolve_selectors(packages, &self.packages, false)?
389 };
390
391 for excluded in resolve_selectors(packages, &self.exclude, true)? {
392 selected.remove(&excluded);
393 }
394
395 Ok(selected)
396 }
397}
398
399fn resolve_selectors(packages: &[verdict::Package], selectors: &[String], allow_missing: bool) -> Result<BTreeSet<PathBuf>> {
401 let mut resolved = BTreeSet::new();
402 for selector in selectors {
403 let matches: Vec<&verdict::Package> = packages
404 .iter()
405 .filter(|package| selector == &package.name || selector == &format!("{}@{}", package.name, package.version))
406 .collect();
407 match matches.as_slice() {
408 [] if allow_missing => {}
409 [] => bail!("package selector `{selector}` did not match any workspace member"),
410 [package] => {
411 resolved.insert(package.manifest_path.clone());
412 }
413 _ => bail!("package selector `{selector}` matched more than one workspace member; qualify it with @version"),
414 }
415 }
416 Ok(resolved)
417}
418
419fn source_checks(
423 manifest_path: &Path,
424 selection_flags: &[OsString],
425 workspace: &Workspace,
426 selected: &BTreeSet<PathBuf>,
427 checks: &[Check],
428) -> Result<bool> {
429 let plain_target_dir = workspace.evidence_target_dir.path().join("plain");
430 let all_target_dir = workspace.evidence_target_dir.path().join("all-targets");
431 let plain = evidence::gather(manifest_path, selection_flags, &plain_target_dir, false)?;
434 let all = evidence::gather(manifest_path, selection_flags, &all_target_dir, true)?;
435
436 let mut candidates = verdict::judge(
442 &workspace.packages,
443 selected,
444 &plain,
445 &all,
446 &doctests::DoctestEvidence::default(),
447 &workspace.allowed,
448 );
449 candidates.retain(|finding| {
450 finding.verdict == Verdict::Unused
454 && finding.section != Section::Build
455 && (Check::Unused.wanted(checks) || finding.section == Section::Normal)
456 });
457 let doctests = doctest_evidence(manifest_path, workspace, &candidates)?;
458
459 let findings = verdict::judge(&workspace.packages, selected, &plain, &all, &doctests, &workspace.allowed);
460
461 let wanted: Vec<&verdict::Finding> = findings
462 .iter()
463 .filter(|finding| match finding.verdict {
464 Verdict::Unused => Check::Unused.wanted(checks),
465 Verdict::Misplaced => Check::Misplaced.wanted(checks),
466 })
467 .collect();
468
469 if wanted.is_empty() {
470 println!(
471 "✅ No selected dependency problems found in {} {}.",
472 selected.len(),
473 if selected.len() == 1 { "package" } else { "packages" }
474 );
475 return Ok(false);
476 }
477
478 report_findings(&wanted);
479
480 Ok(true)
481}
482
483fn doctest_evidence(manifest_path: &Path, workspace: &Workspace, candidates: &[verdict::Finding]) -> Result<doctests::DoctestEvidence> {
488 let mut evidence = doctests::DoctestEvidence::default();
489 if candidates.is_empty() {
490 return Ok(evidence);
491 }
492
493 let shim = std::env::current_exe().context("failed to locate this executable to use as the doctest shim")?;
494 let accused: BTreeSet<&Path> = candidates.iter().map(|finding| finding.manifest_path.as_path()).collect();
495
496 for package in &workspace.packages {
497 if package.has_doctests && accused.contains(package.manifest_path.as_path()) {
500 let selector = format!("{}@{}", package.name, package.version);
501 let found = doctests::gather_package(
502 manifest_path,
503 &selector,
504 &workspace.evidence_target_dir.path().join("doctests"),
505 &shim,
506 )?;
507 evidence.insert(package.manifest_path.clone(), found);
508 }
509 }
510
511 Ok(evidence)
512}
513
514fn report_findings(findings: &[&verdict::Finding]) {
516 eprintln!(
517 "❌ Found {} dependency {}:\n",
518 findings.len(),
519 if findings.len() == 1 { "problem" } else { "problems" }
520 );
521
522 for finding in findings {
523 let section = match finding.section {
524 Section::Normal => "dependencies",
525 Section::Development => "dev-dependencies",
526 Section::Build => "build-dependencies",
527 };
528 let table = dependency_table(finding.target.as_deref(), section);
529
530 match finding.verdict {
531 Verdict::Unused => {
532 eprintln!(" {} {table} {}: no compiled unit loaded it.", finding.package, finding.name);
533 eprintln!(" remove it from {table}, or preserve that scope while narrowing where it is declared.");
534 }
535 Verdict::Misplaced => {
536 let destination = dependency_table(finding.target.as_deref(), "dev-dependencies");
537 eprintln!(" {} {table} {}: only development units load it.", finding.package, finding.name);
538 eprintln!(" move it to {destination}.");
539 }
540 }
541
542 eprintln!(" {}", finding.manifest_path.display());
543 }
544}
545
546fn dependency_table(target: Option<&str>, section: &str) -> String {
548 target.map_or_else(|| format!("[{section}]"), |target| format!("[target.'{target}'.{section}]"))
549}
550
551fn catalog_check(manifest_path: &Path, fix: bool, require_workspace: bool) -> Result<ExitCode> {
553 let original = detect::read_manifest_text(manifest_path)?;
554 let mut manifest = detect::parse_manifest(&original, manifest_path)?;
555
556 let catalog = match detect::catalog(&manifest)? {
557 Catalog::Workspace(catalog) => catalog,
558 Catalog::NotAWorkspace => {
559 if require_workspace {
560 eprintln!("❌ {} has no [workspace] table.", manifest_path.display());
561 return Ok(ExitCode::FAILURE);
562 }
563
564 eprintln!(
565 "ℹ️ {} has no [workspace] table; there is no dependency catalog to check.",
566 manifest_path.display()
567 );
568 return Ok(ExitCode::SUCCESS);
569 }
570 };
571
572 if catalog.declared.is_empty() && catalog.allowed.is_empty() {
573 println!("✅ {} declares no workspace dependencies.", manifest_path.display());
574 return Ok(ExitCode::SUCCESS);
575 }
576
577 let members = members_of(manifest_path)?;
578 let inheritance = detect::inherited(&members)?;
579 let (unused, stale) = detect::partition(&catalog, &inheritance.keys, &inheritance.declarations);
580
581 report_stale(&stale);
582
583 if catalog.declared.is_empty() {
584 println!("✅ {} declares no workspace dependencies.", manifest_path.display());
585 return Ok(ExitCode::SUCCESS);
586 }
587
588 if unused.is_empty() {
589 report_clean(manifest_path, &catalog, &inheritance.keys, members.len());
590 return Ok(ExitCode::SUCCESS);
591 }
592
593 if !fix {
594 report_unused(manifest_path, &unused);
595 return Ok(ExitCode::FAILURE);
596 }
597
598 let outcome = fix::remove(&mut manifest, &unused);
599 verify_members_unchanged(manifest_path, &members)?;
600 write_back(manifest_path, &original, &inheritance.inputs, &manifest.to_string())?;
601
602 println!(
603 "🧹 Removed {} unused workspace {} from {}.",
604 outcome.removed,
605 entries(outcome.removed),
606 manifest_path.display()
607 );
608 report_carries(&outcome.carries);
609
610 Ok(ExitCode::SUCCESS)
611}
612
613fn write_back(manifest_path: &Path, original: &str, member_inputs: &[ManifestInput], contents: &str) -> Result<()> {
639 let resolve_failure = format!("failed to resolve {}", manifest_path.display());
642 let read_failure = format!("failed to re-read {} before writing it", manifest_path.display());
643 let metadata_failure = format!("failed to read the permissions of {}", manifest_path.display());
644 let write_failure = format!("failed to write {}", manifest_path.display());
645 let permissions_failure = format!("failed to apply the permissions of {} to its replacement", manifest_path.display());
646 let persist_failure = format!("failed to replace {}", manifest_path.display());
647
648 let target = fs::canonicalize(manifest_path).context(resolve_failure)?;
651
652 let current = fs::read_to_string(&target).context(read_failure)?;
653 ensure!(
654 current == original,
655 "{} changed on disk while the check was running; not writing",
656 manifest_path.display()
657 );
658
659 for input in member_inputs {
660 let current = fs::read_to_string(&input.path).context(format!("failed to re-read {} before writing", input.path.display()))?;
661 ensure!(
662 current == input.contents,
663 "{} changed on disk while the check was running; not writing",
664 input.path.display()
665 );
666 }
667
668 let permissions = fs::metadata(&target).context(metadata_failure)?.permissions();
669 let directory = target
670 .parent()
671 .expect("a canonicalized file path always names a file, so it always has a parent directory");
672
673 let mut staged = NamedTempFile::new_in(directory).context(write_failure.clone())?;
675 staged.write_all(contents.as_bytes()).context(write_failure)?;
676 staged.as_file().set_permissions(permissions).context(permissions_failure)?;
677 staged.persist(&target).context(persist_failure)?;
678
679 Ok(())
680}
681
682fn members_of(manifest_path: &Path) -> Result<Vec<PathBuf>> {
688 let metadata = MetadataCommand::new()
689 .manifest_path(manifest_path)
690 .no_deps()
691 .exec()
692 .context(format!("failed to enumerate the workspace members of {}", manifest_path.display()))?;
693
694 Ok(metadata
695 .workspace_packages()
696 .into_iter()
697 .map(|package| package.manifest_path.clone().into_std_path_buf())
698 .collect())
699}
700
701fn verify_members_unchanged(manifest_path: &Path, expected: &[PathBuf]) -> Result<()> {
703 let current = members_of(manifest_path)?;
704 let expected: BTreeSet<&Path> = expected.iter().map(PathBuf::as_path).collect();
705 let current: BTreeSet<&Path> = current.iter().map(PathBuf::as_path).collect();
706
707 ensure!(
708 current == expected,
709 "workspace membership changed while the check was running; not writing"
710 );
711
712 Ok(())
713}
714
715fn report_stale(stale: &[String]) {
717 for name in stale {
718 eprintln!("⚠️ '{name}' is allowed but is not declared by the workspace or any member; the allow-list entry can be removed.");
719 }
720}
721
722struct Workspace {
724 packages: Vec<verdict::Package>,
726
727 allowed: BTreeSet<String>,
729
730 evidence_target_dir: tempfile::TempDir,
733}
734
735fn workspace_of(manifest_path: &Path) -> Result<Workspace> {
737 let metadata = MetadataCommand::new()
738 .manifest_path(manifest_path)
739 .no_deps()
740 .exec()
741 .context(format!("failed to enumerate the workspace members of {}", manifest_path.display()))?;
742
743 let mut packages = Vec::new();
744 for package in metadata.workspace_packages() {
745 let path = package.manifest_path.clone().into_std_path_buf();
746 let document = detect::read_manifest(&path)?;
747
748 packages.push(verdict::Package {
749 name: package.name.to_string(),
750 version: package.version.to_string(),
751 declared: detect::declared_dependencies(&document),
752 allowed: detect::package_allowed(&document)?,
753 has_doctests: package.targets.iter().any(|target| {
754 target.kind.iter().any(|kind| {
755 matches!(
756 kind.to_string().as_str(),
757 "lib" | "rlib" | "dylib" | "cdylib" | "staticlib" | "proc-macro"
758 )
759 })
760 }),
761 manifest_path: path,
762 });
763 }
764
765 let root = detect::read_manifest(manifest_path)?;
766 let allowed = match detect::catalog(&root)? {
767 Catalog::Workspace(catalog) => catalog.allowed,
768 Catalog::NotAWorkspace => BTreeSet::new(),
769 };
770
771 let evidence_root = metadata.target_directory.into_std_path_buf().join("unused-deps");
772 fs::create_dir_all(&evidence_root).context(format!("failed to create {}", evidence_root.display()))?;
773 let evidence_target_dir = tempfile::Builder::new()
774 .prefix("run-")
775 .tempdir_in(&evidence_root)
776 .context(format!("failed to create an evidence directory under {}", evidence_root.display()))?;
777
778 Ok(Workspace {
779 packages,
780 allowed,
781 evidence_target_dir,
782 })
783}
784
785fn report_carries(carries: &[Carry]) {
792 for carry in carries {
793 let sources = carry.from.join("', '");
794 match carry.onto.as_ref() {
795 Some(onto) => eprintln!(
796 "⚠️ Carried {} comment {} from '{sources}' onto '{onto}'; check that the text still describes '{onto}'.",
797 carry.lines,
798 lines(carry.lines.get())
799 ),
800 None => eprintln!(
801 "⚠️ Dropped {} comment {} from '{sources}': no surviving entry could carry the comments.",
802 carry.lines,
803 lines(carry.lines.get())
804 ),
805 }
806 }
807}
808
809fn report_clean(manifest_path: &Path, catalog: &WorkspaceCatalog, inherited: &BTreeSet<String>, members: usize) {
811 let declared = catalog.declared.len();
812 let covered = catalog.declared.iter().filter(|name| inherited.contains(name.as_str())).count();
813
814 let qualifier = if declared == covered { "" } else { " or explicitly allowed" };
815
816 println!(
817 "✅ {}: every workspace dependency is inherited by a workspace member{qualifier} (declared: {declared}, members: {members}).",
818 manifest_path.display()
819 );
820}
821
822fn report_unused(manifest_path: &Path, unused: &[String]) {
824 eprintln!(
825 "❌ Found {} unused workspace {} in {}:\n",
826 unused.len(),
827 entries(unused.len()),
828 manifest_path.display()
829 );
830 for name in unused {
831 eprintln!(" - {name}");
832 }
833 eprintln!("\nRe-run with --fix to remove what is listed above.");
834}
835
836fn entries(count: usize) -> &'static str {
838 if count == 1 { "dependency" } else { "dependencies" }
839}
840
841fn lines(count: usize) -> &'static str {
843 if count == 1 { "line" } else { "lines" }
844}
845
846#[cfg(test)]
847mod selection_tests {
848 use std::collections::BTreeSet;
849 use std::ffi::OsString;
850 use std::path::PathBuf;
851
852 use super::{Check, PackageSelection, resolve_selectors, verdict};
853
854 #[test]
855 fn check_filtering_selects_only_requested_findings() {
856 assert!(Check::Unused.wanted(&[]));
857 assert!(Check::Unused.wanted(&[Check::Unused]));
858 assert!(!Check::Unused.wanted(&[Check::Misplaced]));
859 }
860
861 #[test]
862 fn package_selection_forwards_every_cargo_flag() {
863 let selection = PackageSelection {
864 packages: vec!["alpha@1.0.0".to_owned()],
865 workspace: false,
866 exclude: Vec::new(),
867 };
868 assert_eq!(selection.flags(), [OsString::from("--package"), OsString::from("alpha@1.0.0")]);
869
870 let workspace = PackageSelection {
871 packages: Vec::new(),
872 workspace: true,
873 exclude: vec!["beta@1.0.0".to_owned()],
874 };
875 assert_eq!(
876 workspace.flags(),
877 [
878 OsString::from("--workspace"),
879 OsString::from("--exclude"),
880 OsString::from("beta@1.0.0"),
881 ]
882 );
883 }
884
885 #[test]
886 fn ambiguous_package_names_require_a_version() {
887 let packages = [("one", "1.0.0"), ("two", "2.0.0")].map(|(path, version)| verdict::Package {
888 name: "same".to_owned(),
889 version: version.to_owned(),
890 manifest_path: PathBuf::from(path),
891 declared: Vec::new(),
892 allowed: BTreeSet::new(),
893 has_doctests: false,
894 });
895
896 let error = resolve_selectors(&packages, &["same".to_owned()], false).expect_err("ambiguous selectors must fail");
897 assert!(error.to_string().contains("matched more than one workspace member"));
898 }
899
900 #[test]
901 fn missing_exclusions_are_ignored_locally_for_cargo_to_warn_about() {
902 assert!(
903 resolve_selectors(&[], &["optional".to_owned()], true)
904 .expect("missing excludes are allowed")
905 .is_empty()
906 );
907 }
908}
909
910#[cfg(test)]
911#[cfg(not(miri))]
914#[cfg_attr(coverage_nightly, coverage(off))]
915mod tests {
916 use std::fs;
917
918 use tempfile::TempDir;
919
920 use super::{verify_members_unchanged, write_back};
921
922 #[test]
927 fn write_back_replaces_a_manifest_that_is_unchanged() {
928 let dir = TempDir::new().expect("failed to create temp dir");
929 let path = dir.path().join("Cargo.toml");
930 fs::write(&path, "original").expect("failed to seed the manifest");
931
932 write_back(&path, "original", &[], "replacement").expect("an unchanged manifest is replaced");
933
934 assert_eq!(fs::read_to_string(&path).expect("failed to read back"), "replacement");
935 }
936
937 #[test]
938 fn write_back_refuses_a_manifest_that_changed_under_it() {
939 let dir = TempDir::new().expect("failed to create temp dir");
940 let path = dir.path().join("Cargo.toml");
941 fs::write(&path, "edited by someone else").expect("failed to seed the manifest");
942
943 let error = write_back(&path, "original", &[], "replacement").expect_err("a changed manifest is refused");
944
945 assert!(
946 error.to_string().contains("changed on disk while the check was running"),
947 "unexpected error: {error}"
948 );
949 assert_eq!(
950 fs::read_to_string(&path).expect("failed to read back"),
951 "edited by someone else",
952 "the competing edit must survive"
953 );
954 }
955
956 #[test]
957 fn write_back_refuses_a_member_manifest_that_changed_under_it() {
958 let dir = TempDir::new().expect("failed to create temp dir");
959 let root = dir.path().join("Cargo.toml");
960 let member = dir.path().join("member.toml");
961 fs::write(&root, "original").expect("failed to seed the root manifest");
962 fs::write(&member, "edited by someone else").expect("failed to seed the member manifest");
963 let inputs = [crate::detect::ManifestInput {
964 path: member,
965 contents: "member original".to_owned(),
966 }];
967
968 let error = write_back(&root, "original", &inputs, "replacement").expect_err("a changed member manifest is refused");
969
970 assert!(
971 error.to_string().contains("changed on disk while the check was running"),
972 "unexpected error: {error}"
973 );
974 assert_eq!(
975 fs::read_to_string(&root).expect("failed to read back"),
976 "original",
977 "the root manifest must not be replaced"
978 );
979 }
980
981 #[test]
982 fn write_back_refuses_a_member_manifest_that_disappeared() {
983 let dir = TempDir::new().expect("failed to create temp dir");
984 let root = dir.path().join("Cargo.toml");
985 let missing = dir.path().join("missing.toml");
986 fs::write(&root, "original").expect("failed to seed the root manifest");
987 let inputs = [crate::detect::ManifestInput {
988 path: missing.clone(),
989 contents: "member original".to_owned(),
990 }];
991
992 let error = write_back(&root, "original", &inputs, "replacement").expect_err("an unreadable member manifest is refused");
993
994 assert!(
995 error
996 .to_string()
997 .contains(&format!("failed to re-read {} before writing", missing.display())),
998 "unexpected error: {error}"
999 );
1000 assert_eq!(
1001 fs::read_to_string(&root).expect("failed to read back"),
1002 "original",
1003 "the root manifest must not be replaced"
1004 );
1005 }
1006
1007 #[test]
1008 fn membership_comparison_ignores_order() {
1009 let dir = TempDir::new().expect("failed to create temp dir");
1010 let root = dir.path().join("Cargo.toml");
1011 let alpha = dir.path().join("alpha");
1012 let beta = dir.path().join("beta");
1013 fs::create_dir_all(alpha.join("src")).expect("failed to create alpha");
1014 fs::create_dir_all(beta.join("src")).expect("failed to create beta");
1015 fs::write(&root, "[workspace]\nmembers = [\"alpha\", \"beta\"]\nresolver = \"2\"\n").expect("failed to write root manifest");
1016 for (path, name) in [(&alpha, "alpha"), (&beta, "beta")] {
1017 fs::write(
1018 path.join("Cargo.toml"),
1019 format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
1020 )
1021 .expect("failed to write member manifest");
1022 fs::write(path.join("src/lib.rs"), "").expect("failed to write member source");
1023 }
1024 let expected = vec![beta.join("Cargo.toml"), alpha.join("Cargo.toml")];
1025
1026 verify_members_unchanged(&root, &expected).expect("member order is not significant");
1027 }
1028
1029 #[test]
1030 fn membership_comparison_rejects_a_new_member() {
1031 let dir = TempDir::new().expect("failed to create temp dir");
1032 let root = dir.path().join("Cargo.toml");
1033 let member = dir.path().join("member");
1034 fs::create_dir_all(member.join("src")).expect("failed to create member");
1035 fs::write(&root, "[workspace]\nmembers = [\"*\"]\nresolver = \"2\"\n").expect("failed to write root manifest");
1036 fs::write(member.join("Cargo.toml"), "[package]\nname = \"member\"\nversion = \"0.1.0\"\n")
1037 .expect("failed to write member manifest");
1038 fs::write(member.join("src/lib.rs"), "").expect("failed to write member source");
1039
1040 let error = verify_members_unchanged(&root, &[]).expect_err("a newly matched member must invalidate the fix");
1041
1042 assert!(
1043 error
1044 .to_string()
1045 .contains("workspace membership changed while the check was running"),
1046 "unexpected error: {error}"
1047 );
1048 }
1049}