1use std::{
6 collections::{BTreeMap, BTreeSet, HashMap, HashSet},
7 env, fmt, fs,
8 path::{Path, PathBuf},
9};
10
11use anyhow::{Context, Result, bail};
12use thiserror::Error;
13use toml::value::Value;
14use toml_edit::{self, DocumentMut, Item};
15
16use crate::{
17 args::Args,
18 path::{deep_copy, normalize_path, path_relative_to, shortest_new_prefix},
19};
20
21#[derive(Debug)]
25pub(crate) struct CutPlan {
26 root: PathBuf,
29
30 directories: BTreeSet<PathBuf>,
34
35 packages: BTreeMap<String, CutPackage>,
38}
39
40#[derive(Debug, PartialEq, Eq)]
42pub(crate) struct CutPackage {
43 dst_name: String,
44 src_path: PathBuf,
45 dst_path: PathBuf,
46 ws_state: WorkspaceState,
47}
48
49#[derive(Debug, PartialEq, Eq)]
53pub(crate) enum WorkspaceState {
54 Member,
55 Exclude,
56 Unknown,
57}
58
59#[derive(Debug)]
61struct Workspace {
62 members: HashSet<PathBuf>,
64 exclude: HashSet<PathBuf>,
66}
67
68#[derive(Error, Debug)]
69pub(crate) enum Error {
70 #[error("Could not find repository root, please supply one")]
71 NoRoot,
72
73 #[error("No [workspace] found at {}/Cargo.toml", .0.display())]
74 NoWorkspace(PathBuf),
75
76 #[error("Both member and exclude of [workspace]: {}", .0.display())]
77 WorkspaceConflict(PathBuf),
78
79 #[error("Packages '{0}' and '{1}' map to the same cut package name")]
80 PackageConflictName(String, String),
81
82 #[error("Packages '{0}' and '{1}' map to the same cut package path")]
83 PackageConflictPath(String, String),
84
85 #[error("Cutting package '{0}' will overwrite existing path: {}", .1.display())]
86 ExistingPackage(String, PathBuf),
87
88 #[error("'{0}' field is not an array of strings")]
89 NotAStringArray(&'static str),
90
91 #[error("Cannot represent path as a TOML string: {}", .0.display())]
92 PathToTomlStr(PathBuf),
93}
94
95impl CutPlan {
96 pub(crate) fn discover(args: Args) -> Result<Self> {
101 let cwd = env::current_dir()?;
102
103 let Some(root) = args.root.or_else(|| discover_root(cwd)) else {
104 bail!(Error::NoRoot);
105 };
106
107 let root = fs::canonicalize(root)?;
108
109 struct Walker {
110 feature: String,
111 ws: Option<Workspace>,
112 planned_packages: BTreeMap<String, CutPackage>,
113 pending_packages: HashSet<String>,
114 make_directories: BTreeSet<PathBuf>,
115 }
116
117 impl Walker {
118 fn walk(
119 &mut self,
120 src: &Path,
121 dst: &Path,
122 suffix: &Option<String>,
123 mut fresh_parent: bool,
124 ) -> Result<()> {
125 self.try_insert_package(src, dst, suffix)
126 .with_context(|| format!("Failed to plan copy for {}", src.display()))?;
127
128 if !fresh_parent && !dst.exists() {
131 self.make_directories.insert(dst.to_owned());
132 fresh_parent = true;
133 }
134
135 for entry in fs::read_dir(src)? {
136 let entry = entry?;
137 if !entry.file_type()?.is_dir() {
138 continue;
139 }
140
141 if entry.file_name() == "target" {
143 continue;
144 }
145
146 self.walk(
147 &src.join(entry.file_name()),
148 &dst.join(entry.file_name()),
149 suffix,
150 fresh_parent,
151 )?;
152 }
153
154 Ok(())
155 }
156
157 fn try_insert_package(
158 &mut self,
159 src: &Path,
160 dst: &Path,
161 suffix: &Option<String>,
162 ) -> Result<()> {
163 let toml = src.join("Cargo.toml");
164
165 let Some(pkg_name) = package_name(toml)? else {
166 return Ok(());
167 };
168
169 if !self.pending_packages.remove(&pkg_name) {
170 return Ok(());
171 }
172
173 let mut dst_name = suffix
174 .as_ref()
175 .and_then(|s| pkg_name.strip_suffix(s))
176 .unwrap_or(&pkg_name)
177 .to_string();
178
179 dst_name.push('-');
180 dst_name.push_str(&self.feature);
181
182 let dst_path = dst.to_path_buf();
183 if dst_path.exists() {
184 bail!(Error::ExistingPackage(pkg_name, dst_path));
185 }
186
187 self.planned_packages.insert(
188 pkg_name,
189 CutPackage {
190 dst_name,
191 dst_path,
192 src_path: src.to_path_buf(),
193 ws_state: if let Some(ws) = &self.ws {
194 ws.state(src)?
195 } else {
196 WorkspaceState::Unknown
197 },
198 },
199 );
200
201 Ok(())
202 }
203 }
204
205 let mut walker = Walker {
206 feature: args.feature,
207 ws: if args.workspace_update {
208 Some(Workspace::read(&root)?)
209 } else {
210 None
211 },
212 planned_packages: BTreeMap::new(),
213 pending_packages: args.packages.into_iter().collect(),
214 make_directories: BTreeSet::new(),
215 };
216
217 for dir in args.directories {
218 let src_path = fs::canonicalize(&dir.src)
219 .with_context(|| format!("Canonicalizing {} failed", dir.src.display()))?;
220
221 let dst_path = normalize_path(&dir.dst)
228 .with_context(|| format!("Normalizing {} failed", dir.dst.display()))?;
229
230 let fresh_parent = shortest_new_prefix(&dst_path).is_some_and(|pfx| {
233 walker.make_directories.insert(pfx);
234 true
235 });
236
237 walker
238 .walk(
239 &fs::canonicalize(dir.src)?,
240 &dst_path,
241 &dir.suffix,
242 fresh_parent,
243 )
244 .with_context(|| format!("Failed to find packages in {}", src_path.display()))?;
245 }
246
247 for pending in &walker.pending_packages {
249 eprintln!("WARNING: Package '{pending}' not found during scan.");
250 }
251
252 let Walker {
253 planned_packages: packages,
254 make_directories: directories,
255 ..
256 } = walker;
257
258 let mut rev_name = HashMap::new();
260 let mut rev_path = HashMap::new();
261
262 for (name, pkg) in &packages {
263 if let Some(prev) = rev_name.insert(pkg.dst_name.clone(), name.clone()) {
264 bail!(Error::PackageConflictName(name.clone(), prev));
265 }
266
267 if let Some(prev) = rev_path.insert(pkg.dst_path.clone(), name.clone()) {
268 bail!(Error::PackageConflictPath(name.clone(), prev));
269 }
270 }
271
272 Ok(Self {
273 root,
274 packages,
275 directories,
276 })
277 }
278
279 pub(crate) fn execute(&self) -> Result<()> {
283 self.execute_().inspect_err(|_| {
284 self.rollback();
285 })
286 }
287 fn execute_(&self) -> Result<()> {
288 for (name, package) in &self.packages {
289 self.copy_package(package).with_context(|| {
290 format!("Failed to copy package '{name}' to '{}'.", package.dst_name)
291 })?
292 }
293
294 for package in self.packages.values() {
295 self.update_package(package)
296 .with_context(|| format!("Failed to update manifest for '{}'", package.dst_name))?
297 }
298
299 self.update_workspace()
302 .context("Failed to update [workspace].")
303 }
304
305 fn copy_package(&self, package: &CutPackage) -> Result<()> {
308 deep_copy(&package.src_path, &package.dst_path, &mut |src| {
310 src.is_file() || !src.ends_with("target")
311 })?;
312
313 Ok(())
314 }
315
316 fn update_package(&self, package: &CutPackage) -> Result<()> {
322 let path = package.dst_path.join("Cargo.toml");
323 let mut toml = fs::read_to_string(&path)?.parse::<DocumentMut>()?;
324
325 toml["package"]["name"] = toml_edit::value(&package.dst_name);
327
328 self.update_dependencies(&package.src_path, &package.dst_path, toml.as_table_mut())?;
331
332 if let Some(targets) = toml.get_mut("target").and_then(Item::as_table_like_mut) {
333 for (_, target) in targets.iter_mut() {
334 if let Some(target) = target.as_table_like_mut() {
335 self.update_dependencies(&package.src_path, &package.dst_path, target)?;
336 };
337 }
338 };
339
340 fs::write(&path, toml.to_string())?;
341 Ok(())
342 }
343
344 fn update_dependencies(
349 &self,
350 src_path: impl AsRef<Path>,
351 dst_path: impl AsRef<Path>,
352 table: &mut dyn toml_edit::TableLike,
353 ) -> Result<()> {
354 for field in ["dependencies", "dev-dependencies", "build-dependencies"] {
355 let Some(deps) = table.get_mut(field).and_then(Item::as_table_like_mut) else {
356 continue;
357 };
358
359 for (dep_name, dep) in deps.iter_mut() {
360 self.update_dependency(&src_path, &dst_path, dep_name, dep)?
361 }
362 }
363
364 Ok(())
365 }
366
367 fn update_dependency(
377 &self,
378 src_path: impl AsRef<Path>,
379 dst_path: impl AsRef<Path>,
380 dep_name: toml_edit::KeyMut,
381 dep: &mut Item,
382 ) -> Result<()> {
383 let Some(dep) = dep.as_table_like_mut() else {
384 return Ok(());
385 };
386
387 let dep_pkg = self.packages.get(
390 dep.get("package")
391 .and_then(Item::as_str)
392 .unwrap_or_else(|| dep_name.get()),
393 );
394
395 let Some(path) = dep.get_mut("path") else {
397 return Ok(());
398 };
399
400 if let Some(dep_pkg) = dep_pkg {
401 *path = toml_edit::value(path_to_toml_value(dst_path, &dep_pkg.dst_path)?);
403 if dep_name.get() != dep_pkg.dst_name {
404 dep.insert("package", toml_edit::value(&dep_pkg.dst_name));
405 }
406 } else if let Some(rel_dep_path) = path.as_str() {
407 let dep_path = src_path.as_ref().join(rel_dep_path);
410 *path = toml_edit::value(path_to_toml_value(dst_path, dep_path)?);
411 }
412
413 Ok(())
414 }
415
416 fn update_workspace(&self) -> Result<()> {
419 let path = self.root.join("Cargo.toml");
420 if !path.exists() {
421 bail!(Error::NoWorkspace(path));
422 }
423
424 let mut toml = fs::read_to_string(&path)?.parse::<DocumentMut>()?;
425 for package in self.packages.values() {
426 match package.ws_state {
427 WorkspaceState::Unknown => {
428 continue;
429 }
430
431 WorkspaceState::Member => {
432 let Some(members) = toml["workspace"]["members"].as_array_mut() else {
435 bail!(Error::NotAStringArray("members"));
436 };
437
438 let pkg_path = path_to_toml_value(&self.root, &package.dst_path)?;
439 members.push(pkg_path);
440 }
441
442 WorkspaceState::Exclude => {
443 let Some(exclude) = toml["workspace"]["exclude"].as_array_mut() else {
446 bail!(Error::NotAStringArray("exclude"));
447 };
448
449 let pkg_path = path_to_toml_value(&self.root, &package.dst_path)?;
450 exclude.push(pkg_path);
451 }
452 };
453 }
454
455 if let Some(members) = toml
456 .get_mut("workspace")
457 .and_then(|w| w.get_mut("members"))
458 .and_then(|m| m.as_array_mut())
459 {
460 format_array_of_strings("members", members)?
461 }
462
463 if let Some(exclude) = toml
464 .get_mut("workspace")
465 .and_then(|w| w.get_mut("exclude"))
466 .and_then(|m| m.as_array_mut())
467 {
468 format_array_of_strings("exclude", exclude)?
469 }
470
471 fs::write(&path, toml.to_string())?;
472 Ok(())
473 }
474
475 fn rollback(&self) {
481 for dir in &self.directories {
482 if let Err(e) = fs::remove_dir_all(dir) {
483 eprintln!("Rollback Error deleting {}: {e}", dir.display());
484 }
485 }
486 }
487}
488
489impl Workspace {
490 fn read<P: AsRef<Path>>(root: P) -> Result<Self> {
495 let path = root.as_ref().join("Cargo.toml");
496 if !path.exists() {
497 bail!(Error::NoWorkspace(path));
498 }
499
500 let toml = toml::de::from_str::<Value>(&fs::read_to_string(&path)?)?;
501 let Some(workspace) = toml.get("workspace") else {
502 bail!(Error::NoWorkspace(path));
503 };
504
505 let members = toml_path_array_to_set(root.as_ref(), workspace, "members")
506 .context("Failed to read workspace.members")?;
507 let exclude = toml_path_array_to_set(root.as_ref(), workspace, "exclude")
508 .context("Failed to read workspace.exclude")?;
509
510 Ok(Self { members, exclude })
511 }
512
513 fn state<P: AsRef<Path>>(&self, path: P) -> Result<WorkspaceState> {
516 let path = path.as_ref();
517 match (self.members.contains(path), self.exclude.contains(path)) {
518 (true, true) => bail!(Error::WorkspaceConflict(path.to_path_buf())),
519
520 (true, false) => Ok(WorkspaceState::Member),
521 (false, true) => Ok(WorkspaceState::Exclude),
522 (false, false) => Ok(WorkspaceState::Unknown),
523 }
524 }
525}
526
527impl fmt::Display for CutPlan {
528 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
529 writeln!(f, "Copying packages in: {}", self.root.display())?;
530
531 fn write_package(
532 root: &Path,
533 name: &str,
534 pkg: &CutPackage,
535 f: &mut fmt::Formatter<'_>,
536 ) -> fmt::Result {
537 let dst_path = pkg.dst_path.strip_prefix(root).unwrap_or(&pkg.dst_path);
538
539 let src_path = pkg.src_path.strip_prefix(root).unwrap_or(&pkg.src_path);
540
541 writeln!(f, " - to: {}", pkg.dst_name)?;
542 writeln!(f, " {}", dst_path.display())?;
543 writeln!(f, " from: {name}")?;
544 writeln!(f, " {}", src_path.display())?;
545 Ok(())
546 }
547
548 writeln!(f)?;
549 writeln!(f, "new [workspace] members:")?;
550 for (name, package) in &self.packages {
551 if package.ws_state == WorkspaceState::Member {
552 write_package(&self.root, name, package, f)?
553 }
554 }
555
556 writeln!(f)?;
557 writeln!(f, "new [workspace] excludes:")?;
558 for (name, package) in &self.packages {
559 if package.ws_state == WorkspaceState::Exclude {
560 write_package(&self.root, name, package, f)?
561 }
562 }
563
564 writeln!(f)?;
565 writeln!(f, "other packages:")?;
566 for (name, package) in &self.packages {
567 if package.ws_state == WorkspaceState::Unknown {
568 write_package(&self.root, name, package, f)?
569 }
570 }
571
572 Ok(())
573 }
574}
575
576fn discover_root(mut cwd: PathBuf) -> Option<PathBuf> {
581 cwd.extend(["_", ".git"]);
582 while {
583 cwd.pop();
584 cwd.pop()
585 } {
586 cwd.push(".git");
587 if cwd.is_dir() {
588 cwd.pop();
589 return Some(cwd);
590 }
591 }
592
593 None
594}
595
596fn toml_path_array_to_set<P: AsRef<Path>>(
602 root: P,
603 table: &Value,
604 field: &'static str,
605) -> Result<HashSet<PathBuf>> {
606 let mut set = HashSet::new();
607
608 let Some(array) = table.get(field) else {
609 return Ok(set);
610 };
611 let Some(array) = array.as_array() else {
612 bail!(Error::NotAStringArray(field))
613 };
614
615 for val in array {
616 let Some(path) = val.as_str() else {
617 bail!(Error::NotAStringArray(field));
618 };
619
620 set.insert(
621 fs::canonicalize(root.as_ref().join(path))
622 .with_context(|| format!("Canonicalizing path '{path}'"))?,
623 );
624 }
625
626 Ok(set)
627}
628
629fn path_to_toml_value<P, Q>(root: P, path: Q) -> Result<toml_edit::Value>
634where
635 P: AsRef<Path>,
636 Q: AsRef<Path>,
637{
638 let path = path_relative_to(root, path)?;
639 let Some(repr) = path.to_str() else {
640 bail!(Error::PathToTomlStr(path));
641 };
642
643 Ok(repr.into())
644}
645
646fn format_array_of_strings(field: &'static str, array: &mut toml_edit::Array) -> Result<()> {
649 let mut strs = BTreeSet::new();
650 for item in &*array {
651 let Some(s) = item.as_str() else {
652 bail!(Error::NotAStringArray(field));
653 };
654
655 strs.insert(s.to_owned());
656 }
657
658 array.set_trailing_comma(true);
659 array.set_trailing("\n");
660 array.clear();
661
662 for s in strs {
663 array.push_formatted(toml_edit::Value::from(s).decorated("\n ", ""));
664 }
665
666 Ok(())
667}
668
669fn package_name<P: AsRef<Path>>(path: P) -> Result<Option<String>> {
670 if !path.as_ref().is_file() {
671 return Ok(None);
672 }
673
674 let content = fs::read_to_string(&path)?;
675 let toml = toml::de::from_str::<Value>(&content)?;
676
677 let Some(package) = toml.get("package") else {
678 return Ok(None);
679 };
680
681 let Some(name) = package.get("name") else {
682 return Ok(None);
683 };
684
685 Ok(name.as_str().map(str::to_string))
686}
687
688#[cfg(test)]
689mod tests {
690 use std::{fmt, fs, path::PathBuf};
691
692 use expect_test::expect;
693 use tempfile::tempdir;
694
695 use super::*;
696 use crate::args::Directory;
697
698 #[test]
699 fn test_discover_root() {
700 let cut = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
701
702 let Some(root) = discover_root(cut.clone()) else {
703 panic!("Failed to discover root from: {}", cut.display());
704 };
705
706 assert!(cut.starts_with(root));
707 }
708
709 #[test]
710 fn test_discover_root_idempotence() {
711 let cut = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
712
713 let Some(root) = discover_root(cut.clone()) else {
714 panic!("Failed to discover root from: {}", cut.display());
715 };
716
717 let Some(root_again) = discover_root(root.clone()) else {
718 panic!("Failed to discover root from itself: {}", root.display());
719 };
720
721 assert_eq!(root, root_again);
722 }
723
724 #[test]
725 fn test_discover_root_non_existent() {
726 let tmp = tempdir().unwrap();
727 assert_eq!(None, discover_root(tmp.path().to_owned()));
728 }
729
730 #[test]
731 fn test_workspace_read() {
732 let cut = fs::canonicalize(env!("CARGO_MANIFEST_DIR")).unwrap();
733 let root = discover_root(cut.clone()).unwrap();
734
735 let iota_execution = root.join("iota-execution");
736 let move_vm_types = root.join("external-crates/move/crates/move-vm-types");
737
738 let ws = Workspace::read(&root).unwrap();
739
740 assert!(ws.members.contains(&cut));
742
743 assert!(ws.members.contains(&iota_execution));
745 assert!(ws.exclude.contains(&move_vm_types));
746 }
747
748 #[test]
749 fn test_no_workspace() {
750 let err = Workspace::read(env!("CARGO_MANIFEST_DIR")).unwrap_err();
751 expect!["No [workspace] found at $PATH/iota-execution/cut/Cargo.toml/Cargo.toml"]
752 .assert_eq(&scrub_path(&format!("{:#}", err), repo_root()));
753 }
754
755 #[test]
756 fn test_empty_workspace() {
757 let tmp = tempdir().unwrap();
758 let toml = tmp.path().join("Cargo.toml");
759
760 fs::write(
761 toml,
762 r#"
763 [workspace]
764 "#,
765 )
766 .unwrap();
767
768 let ws = Workspace::read(&tmp).unwrap();
769 assert!(ws.members.is_empty());
770 assert!(ws.exclude.is_empty());
771 }
772
773 #[test]
774 fn test_bad_workspace_field() {
775 let tmp = tempdir().unwrap();
776 let toml = tmp.path().join("Cargo.toml");
777
778 fs::write(
779 toml,
780 r#"
781 [workspace]
782 members = [1, 2, 3]
783 "#,
784 )
785 .unwrap();
786
787 let err = Workspace::read(&tmp).unwrap_err();
788 expect!["Failed to read workspace.members: 'members' field is not an array of strings"]
789 .assert_eq(&scrub_path(&format!("{:#}", err), repo_root()));
790 }
791
792 #[test]
793 fn test_bad_workspace_path() {
794 let tmp = tempdir().unwrap();
795 let toml = tmp.path().join("Cargo.toml");
796
797 fs::write(
798 toml,
799 r#"
800 [workspace]
801 members = ["i_dont_exist"]
802 "#,
803 )
804 .unwrap();
805
806 let err = Workspace::read(&tmp).unwrap_err();
807 expect!["Failed to read workspace.members: Canonicalizing path 'i_dont_exist': No such file or directory (os error 2)"]
808 .assert_eq(&scrub_path(&format!("{:#}", err), repo_root()));
809 }
810
811 #[test]
812 fn test_cut_plan_discover() {
813 let cut = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
814
815 let plan = CutPlan::discover(Args {
816 dry_run: false,
817 workspace_update: true,
818 feature: "feature".to_string(),
819 root: None,
820 directories: vec![
821 Directory {
822 src: cut.join("../latest"),
823 dst: cut.join("../exec-cut"),
824 suffix: Some("-latest".to_string()),
825 },
826 Directory {
827 src: cut.clone(),
828 dst: cut.join("../cut-cut"),
829 suffix: None,
830 },
831 Directory {
832 src: cut.join("../../external-crates/move/crates/move-core-types"),
833 dst: cut.join("../cut-move-core-types"),
834 suffix: None,
835 },
836 ],
837 packages: vec![
838 "move-core-types".to_string(),
839 "iota-adapter-latest".to_string(),
840 "iota-execution-cut".to_string(),
841 "iota-verifier-latest".to_string(),
842 ],
843 })
844 .unwrap();
845
846 expect![[r#"
847 CutPlan {
848 root: "$PATH",
849 directories: {
850 "$PATH/iota-execution/cut-cut",
851 "$PATH/iota-execution/cut-move-core-types",
852 "$PATH/iota-execution/exec-cut",
853 },
854 packages: {
855 "iota-adapter-latest": CutPackage {
856 dst_name: "iota-adapter-feature",
857 src_path: "$PATH/iota-execution/latest/iota-adapter",
858 dst_path: "$PATH/iota-execution/exec-cut/iota-adapter",
859 ws_state: Member,
860 },
861 "iota-execution-cut": CutPackage {
862 dst_name: "iota-execution-cut-feature",
863 src_path: "$PATH/iota-execution/cut",
864 dst_path: "$PATH/iota-execution/cut-cut",
865 ws_state: Member,
866 },
867 "iota-verifier-latest": CutPackage {
868 dst_name: "iota-verifier-feature",
869 src_path: "$PATH/iota-execution/latest/iota-verifier",
870 dst_path: "$PATH/iota-execution/exec-cut/iota-verifier",
871 ws_state: Member,
872 },
873 "move-core-types": CutPackage {
874 dst_name: "move-core-types-feature",
875 src_path: "$PATH/external-crates/move/crates/move-core-types",
876 dst_path: "$PATH/iota-execution/cut-move-core-types",
877 ws_state: Exclude,
878 },
879 },
880 }"#]]
881 .assert_eq(&debug_for_test(&plan));
882
883 expect![[r#"
884 Copying packages in: $PATH
885
886 new [workspace] members:
887 - to: iota-adapter-feature
888 iota-execution/exec-cut/iota-adapter
889 from: iota-adapter-latest
890 iota-execution/latest/iota-adapter
891 - to: iota-execution-cut-feature
892 iota-execution/cut-cut
893 from: iota-execution-cut
894 iota-execution/cut
895 - to: iota-verifier-feature
896 iota-execution/exec-cut/iota-verifier
897 from: iota-verifier-latest
898 iota-execution/latest/iota-verifier
899
900 new [workspace] excludes:
901 - to: move-core-types-feature
902 iota-execution/cut-move-core-types
903 from: move-core-types
904 external-crates/move/crates/move-core-types
905
906 other packages:
907 "#]]
908 .assert_eq(&display_for_test(&plan));
909 }
910
911 #[test]
912 fn test_cut_plan_discover_new_top_level_destination() {
913 let cut = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
914
915 let plan = CutPlan::discover(Args {
919 dry_run: false,
920 workspace_update: true,
921 feature: "feature".to_string(),
922 root: None,
923 directories: vec![
924 Directory {
925 src: cut.join("../latest"),
926 dst: cut.join("../feature"),
927 suffix: Some("-latest".to_string()),
928 },
929 Directory {
930 src: cut.clone(),
931 dst: cut.join("../feature/cut"),
932 suffix: None,
933 },
934 Directory {
935 src: cut.join("../../external-crates/move"),
936 dst: cut.join("../feature/move"),
937 suffix: None,
938 },
939 ],
940 packages: vec![
941 "move-core-types".to_string(),
942 "iota-adapter-latest".to_string(),
943 "iota-execution-cut".to_string(),
944 "iota-verifier-latest".to_string(),
945 ],
946 })
947 .unwrap();
948
949 expect![[r#"
950 CutPlan {
951 root: "$PATH",
952 directories: {
953 "$PATH/iota-execution/feature",
954 },
955 packages: {
956 "iota-adapter-latest": CutPackage {
957 dst_name: "iota-adapter-feature",
958 src_path: "$PATH/iota-execution/latest/iota-adapter",
959 dst_path: "$PATH/iota-execution/feature/iota-adapter",
960 ws_state: Member,
961 },
962 "iota-execution-cut": CutPackage {
963 dst_name: "iota-execution-cut-feature",
964 src_path: "$PATH/iota-execution/cut",
965 dst_path: "$PATH/iota-execution/feature/cut",
966 ws_state: Member,
967 },
968 "iota-verifier-latest": CutPackage {
969 dst_name: "iota-verifier-feature",
970 src_path: "$PATH/iota-execution/latest/iota-verifier",
971 dst_path: "$PATH/iota-execution/feature/iota-verifier",
972 ws_state: Member,
973 },
974 "move-core-types": CutPackage {
975 dst_name: "move-core-types-feature",
976 src_path: "$PATH/external-crates/move/crates/move-core-types",
977 dst_path: "$PATH/iota-execution/feature/move/crates/move-core-types",
978 ws_state: Exclude,
979 },
980 },
981 }"#]]
982 .assert_eq(&debug_for_test(&plan));
983 }
984
985 #[test]
986 fn test_cut_plan_workspace_conflict() {
987 let tmp = tempdir().unwrap();
988 fs::create_dir(tmp.path().join("foo")).unwrap();
989
990 fs::write(
991 tmp.path().join("Cargo.toml"),
992 r#"
993 [workspace]
994 members = ["foo"]
995 exclude = ["foo"]
996 "#,
997 )
998 .unwrap();
999
1000 fs::write(
1001 tmp.path().join("foo/Cargo.toml"),
1002 r#"
1003 [package]
1004 name = "foo"
1005 "#,
1006 )
1007 .unwrap();
1008
1009 let err = CutPlan::discover(Args {
1010 dry_run: false,
1011 workspace_update: true,
1012 feature: "feature".to_string(),
1013 root: Some(tmp.path().to_owned()),
1014 directories: vec![Directory {
1015 src: tmp.path().to_owned(),
1016 dst: tmp.path().join("cut"),
1017 suffix: None,
1018 }],
1019 packages: vec!["foo".to_string()],
1020 })
1021 .unwrap_err();
1022
1023 expect!["Failed to find packages in $PATH: Failed to plan copy for $PATH/foo: Both member and exclude of [workspace]: $PATH/foo"]
1024 .assert_eq(&scrub_path(&format!("{:#}", err), tmp.path()));
1025 }
1026
1027 #[test]
1028 fn test_cut_plan_package_name_conflict() {
1029 let tmp = tempdir().unwrap();
1030 fs::create_dir_all(tmp.path().join("foo/bar-latest")).unwrap();
1031 fs::create_dir_all(tmp.path().join("baz/bar")).unwrap();
1032
1033 fs::write(tmp.path().join("Cargo.toml"), "[workspace]").unwrap();
1034
1035 fs::write(
1036 tmp.path().join("foo/bar-latest/Cargo.toml"),
1037 r#"package.name = "bar-latest""#,
1038 )
1039 .unwrap();
1040
1041 fs::write(
1042 tmp.path().join("baz/bar/Cargo.toml"),
1043 r#"package.name = "bar""#,
1044 )
1045 .unwrap();
1046
1047 let err = CutPlan::discover(Args {
1048 dry_run: false,
1049 workspace_update: true,
1050 feature: "feature".to_string(),
1051 root: Some(tmp.path().to_owned()),
1052 directories: vec![
1053 Directory {
1054 src: tmp.path().join("foo"),
1055 dst: tmp.path().join("cut"),
1056 suffix: Some("-latest".to_string()),
1057 },
1058 Directory {
1059 src: tmp.path().join("baz"),
1060 dst: tmp.path().join("cut"),
1061 suffix: None,
1062 },
1063 ],
1064 packages: vec!["bar-latest".to_string(), "bar".to_string()],
1065 })
1066 .unwrap_err();
1067
1068 expect!["Packages 'bar-latest' and 'bar' map to the same cut package name"]
1069 .assert_eq(&format!("{:#}", err));
1070 }
1071
1072 #[test]
1073 fn test_cut_plan_package_path_conflict() {
1074 let tmp = tempdir().unwrap();
1075 fs::create_dir_all(tmp.path().join("foo/bar")).unwrap();
1076 fs::create_dir_all(tmp.path().join("baz/bar")).unwrap();
1077
1078 fs::write(tmp.path().join("Cargo.toml"), "[workspace]").unwrap();
1079
1080 fs::write(
1081 tmp.path().join("foo/bar/Cargo.toml"),
1082 r#"package.name = "foo-bar""#,
1083 )
1084 .unwrap();
1085
1086 fs::write(
1087 tmp.path().join("baz/bar/Cargo.toml"),
1088 r#"package.name = "baz-bar""#,
1089 )
1090 .unwrap();
1091
1092 let err = CutPlan::discover(Args {
1093 dry_run: false,
1094 workspace_update: true,
1095 feature: "feature".to_string(),
1096 root: Some(tmp.path().to_owned()),
1097 directories: vec![
1098 Directory {
1099 src: tmp.path().join("foo"),
1100 dst: tmp.path().join("cut"),
1101 suffix: None,
1102 },
1103 Directory {
1104 src: tmp.path().join("baz"),
1105 dst: tmp.path().join("cut"),
1106 suffix: None,
1107 },
1108 ],
1109 packages: vec!["foo-bar".to_string(), "baz-bar".to_string()],
1110 })
1111 .unwrap_err();
1112
1113 expect!["Packages 'foo-bar' and 'baz-bar' map to the same cut package path"]
1114 .assert_eq(&format!("{:#}", err));
1115 }
1116
1117 #[test]
1118 fn test_cut_plan_existing_package() {
1119 let tmp = tempdir().unwrap();
1120 fs::create_dir_all(tmp.path().join("foo/bar")).unwrap();
1121 fs::create_dir_all(tmp.path().join("baz/bar")).unwrap();
1122
1123 fs::write(tmp.path().join("Cargo.toml"), "[workspace]").unwrap();
1124
1125 fs::write(
1126 tmp.path().join("foo/bar/Cargo.toml"),
1127 r#"package.name = "foo-bar""#,
1128 )
1129 .unwrap();
1130
1131 fs::write(
1132 tmp.path().join("baz/bar/Cargo.toml"),
1133 r#"package.name = "baz-bar""#,
1134 )
1135 .unwrap();
1136
1137 let err = CutPlan::discover(Args {
1138 dry_run: false,
1139 workspace_update: true,
1140 feature: "feature".to_string(),
1141 root: Some(tmp.path().to_owned()),
1142 directories: vec![Directory {
1143 src: tmp.path().join("foo"),
1144 dst: tmp.path().join("baz"),
1145 suffix: None,
1146 }],
1147 packages: vec!["foo-bar".to_string()],
1148 })
1149 .unwrap_err();
1150
1151 expect!["Failed to find packages in $PATH/foo: Failed to plan copy for $PATH/foo/bar: Cutting package 'foo-bar' will overwrite existing path: $PATH/baz/bar"]
1152 .assert_eq(&scrub_path(&format!("{:#}", err), tmp.path()));
1153 }
1154
1155 #[test]
1156 fn test_cut_plan_execute_and_rollback() {
1157 let tmp = tempdir().unwrap();
1158 let root = tmp.path().to_owned();
1159
1160 fs::create_dir_all(root.join("crates/foo/../bar/../baz/../qux/../quy")).unwrap();
1161
1162 fs::write(
1163 root.join("Cargo.toml"),
1164 [
1165 r#"[workspace]"#,
1166 r#"members = ["crates/foo"]"#,
1167 r#"exclude = ["#,
1168 r#" "crates/bar","#,
1169 r#" "crates/qux","#,
1170 r#"]"#,
1171 ]
1172 .join("\n"),
1173 )
1174 .unwrap();
1175
1176 fs::write(
1177 root.join("crates/foo/Cargo.toml"),
1178 r#"package.name = "foo-latest""#,
1179 )
1180 .unwrap();
1181
1182 fs::write(
1183 root.join("crates/bar/Cargo.toml"),
1184 [
1185 r#"[package]"#,
1186 r#"name = "bar""#,
1187 r#""#,
1188 r#"[dependencies]"#,
1189 r#"foo = { path = "../foo", package = "foo-latest" }"#,
1190 r#""#,
1191 r#"[dev-dependencies]"#,
1192 r#"baz = { path = "../baz" }"#,
1193 r#"quy = { path = "../quy" }"#,
1194 ]
1195 .join("\n"),
1196 )
1197 .unwrap();
1198
1199 fs::write(
1200 root.join("crates/baz/Cargo.toml"),
1201 [
1202 r#"[package]"#,
1203 r#"name = "baz""#,
1204 r#""#,
1205 r#"[dependencies]"#,
1206 r#"acme = "1.0.0""#,
1207 r#""#,
1208 r#"[build-dependencies]"#,
1209 r#"bar = { path = "../bar" }"#,
1210 ]
1211 .join("\n"),
1212 )
1213 .unwrap();
1214
1215 fs::write(
1216 root.join("crates/qux/Cargo.toml"),
1217 [
1218 r#"[package]"#,
1219 r#"name = "qux""#,
1220 r#""#,
1221 r#"[target.'cfg(unix)'.dependencies]"#,
1222 r#"bar = { path = "../bar" }"#,
1223 r#""#,
1224 r#"[target.'cfg(target_arch = "x86_64")'.build-dependencies]"#,
1225 r#"foo = { path = "../foo", package = "foo-latest" }"#,
1226 ]
1227 .join("\n"),
1228 )
1229 .unwrap();
1230
1231 fs::write(
1232 root.join("crates/quy/Cargo.toml"),
1233 [r#"[package]"#, r#"name = "quy""#].join("\n"),
1234 )
1235 .unwrap();
1236
1237 let plan = CutPlan::discover(Args {
1238 dry_run: false,
1239 workspace_update: true,
1240 feature: "cut".to_string(),
1241 root: Some(tmp.path().to_owned()),
1242 directories: vec![Directory {
1243 src: root.join("crates"),
1244 dst: root.join("cut"),
1245 suffix: Some("-latest".to_owned()),
1246 }],
1247 packages: vec![
1248 "foo-latest".to_string(),
1249 "bar".to_string(),
1250 "baz".to_string(),
1251 "qux".to_string(),
1252 ],
1253 })
1254 .unwrap();
1255
1256 plan.execute().unwrap();
1257
1258 assert!(!root.join("cut/quy").exists());
1259
1260 expect![[r#"
1261 [workspace]
1262 members = [
1263 "crates/foo",
1264 "cut/foo",
1265 ]
1266 exclude = [
1267 "crates/bar",
1268 "crates/qux",
1269 "cut/bar",
1270 "cut/qux",
1271 ]
1272
1273 ---
1274 package.name = "foo-cut"
1275
1276 ---
1277 [package]
1278 name = "bar-cut"
1279
1280 [dependencies]
1281 foo = { path = "../foo", package = "foo-cut" }
1282
1283 [dev-dependencies]
1284 baz = { path = "../baz", package = "baz-cut" }
1285 quy = { path = "../../crates/quy" }
1286
1287 ---
1288 [package]
1289 name = "baz-cut"
1290
1291 [dependencies]
1292 acme = "1.0.0"
1293
1294 [build-dependencies]
1295 bar = { path = "../bar", package = "bar-cut" }
1296
1297 ---
1298 [package]
1299 name = "qux-cut"
1300
1301 [target.'cfg(unix)'.dependencies]
1302 bar = { path = "../bar", package = "bar-cut" }
1303
1304 [target.'cfg(target_arch = "x86_64")'.build-dependencies]
1305 foo = { path = "../foo", package = "foo-cut" }
1306 "#]]
1307 .assert_eq(&read_files([
1308 root.join("Cargo.toml"),
1309 root.join("cut/foo/Cargo.toml"),
1310 root.join("cut/bar/Cargo.toml"),
1311 root.join("cut/baz/Cargo.toml"),
1312 root.join("cut/qux/Cargo.toml"),
1313 ]));
1314
1315 plan.rollback();
1316 assert!(!root.join("cut").exists())
1317 }
1318
1319 #[test]
1320 fn test_cut_plan_no_workspace_update() {
1321 let cut = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
1322
1323 let plan = CutPlan::discover(Args {
1324 dry_run: false,
1325 workspace_update: false,
1326 feature: "feature".to_string(),
1327 root: None,
1328 directories: vec![
1329 Directory {
1330 src: cut.join("../latest"),
1331 dst: cut.join("../exec-cut"),
1332 suffix: Some("-latest".to_string()),
1333 },
1334 Directory {
1335 src: cut.clone(),
1336 dst: cut.join("../cut-cut"),
1337 suffix: None,
1338 },
1339 Directory {
1340 src: cut.join("../../external-crates/move/crates/move-core-types"),
1341 dst: cut.join("../cut-move-core-types"),
1342 suffix: None,
1343 },
1344 ],
1345 packages: vec![
1346 "move-core-types".to_string(),
1347 "iota-adapter-latest".to_string(),
1348 "iota-execution-cut".to_string(),
1349 "iota-verifier-latest".to_string(),
1350 ],
1351 })
1352 .unwrap();
1353
1354 expect![[r#"
1355 CutPlan {
1356 root: "$PATH",
1357 directories: {
1358 "$PATH/iota-execution/cut-cut",
1359 "$PATH/iota-execution/cut-move-core-types",
1360 "$PATH/iota-execution/exec-cut",
1361 },
1362 packages: {
1363 "iota-adapter-latest": CutPackage {
1364 dst_name: "iota-adapter-feature",
1365 src_path: "$PATH/iota-execution/latest/iota-adapter",
1366 dst_path: "$PATH/iota-execution/exec-cut/iota-adapter",
1367 ws_state: Unknown,
1368 },
1369 "iota-execution-cut": CutPackage {
1370 dst_name: "iota-execution-cut-feature",
1371 src_path: "$PATH/iota-execution/cut",
1372 dst_path: "$PATH/iota-execution/cut-cut",
1373 ws_state: Unknown,
1374 },
1375 "iota-verifier-latest": CutPackage {
1376 dst_name: "iota-verifier-feature",
1377 src_path: "$PATH/iota-execution/latest/iota-verifier",
1378 dst_path: "$PATH/iota-execution/exec-cut/iota-verifier",
1379 ws_state: Unknown,
1380 },
1381 "move-core-types": CutPackage {
1382 dst_name: "move-core-types-feature",
1383 src_path: "$PATH/external-crates/move/crates/move-core-types",
1384 dst_path: "$PATH/iota-execution/cut-move-core-types",
1385 ws_state: Unknown,
1386 },
1387 },
1388 }"#]]
1389 .assert_eq(&debug_for_test(&plan));
1390 }
1391
1392 fn debug_for_test<T: fmt::Debug>(x: &T) -> String {
1395 scrub_path(&format!("{x:#?}"), repo_root())
1396 }
1397
1398 fn display_for_test<T: fmt::Display>(x: &T) -> String {
1401 scrub_path(&format!("{x}"), repo_root())
1402 }
1403
1404 fn read_files<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> String {
1406 let contents: Vec<_> = paths
1407 .into_iter()
1408 .map(|p| fs::read_to_string(p).unwrap())
1409 .collect();
1410
1411 contents.join("\n---\n")
1412 }
1413
1414 fn scrub_path<P: AsRef<Path>>(x: &str, p: P) -> String {
1415 let path0 = fs::canonicalize(&p)
1416 .unwrap()
1417 .into_os_string()
1418 .into_string()
1419 .unwrap();
1420
1421 let path1 = p.as_ref().as_os_str().to_os_string().into_string().unwrap();
1422
1423 x.replace(&path0, "$PATH").replace(&path1, "$PATH")
1424 }
1425
1426 fn repo_root() -> PathBuf {
1427 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
1428 }
1429}