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