cut/
plan.rs

1// Copyright (c) Mysten Labs, Inc.
2// SPDX-License-Identifier: Apache-2.0
3
4use 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/// Description of where packages should be copied to, what their new names should be, and whether
18/// they should be added to the `workspace` `members` or `exclude` fields.
19#[derive(Debug)]
20pub(crate) struct CutPlan {
21    /// Root of the repository, where the `Cargo.toml` containing the `workspace` configuration is
22    /// found.
23    root: PathBuf,
24
25    /// New directories that need to be created.  Used to clean-up copied packages on roll-back.  If
26    /// multiple nested directories must be created, only contains their shortest common prefix.
27    directories: BTreeSet<PathBuf>,
28
29    /// Mapping from the names of existing packages to be cut, to the details of where they will be
30    /// copied to.
31    packages: BTreeMap<String, CutPackage>,
32}
33
34/// Details for an individual copied package in the feature being cut.
35#[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/// Whether the package in question is an explicit member of the workspace, an explicit exclude of
44/// the workspace, or neither (in which case it could still transitively be one or the other).
45#[derive(Debug, PartialEq, Eq)]
46pub(crate) enum WorkspaceState {
47    Member,
48    Exclude,
49    Unknown,
50}
51
52/// Relevant contents of a Cargo.toml `workspace` section.
53#[derive(Debug)]
54struct Workspace {
55    /// Canonicalized paths of workspace members
56    members: HashSet<PathBuf>,
57    /// Canonicalized paths of workspace excludes
58    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    /// Scan `args.directories` looking for `args.packages` to produce a new plan.  The resulting
90    /// plan is guaranteed not to contain any duplicate packages (by name or path), or overwrite any
91    /// existing packages.  Returns an error if it's not possible to construct such a plan.
92    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                // Figure out whether the parent directory was already created, or whether this
121                // directory needs to be created.
122                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                    // Skip `target` directories.
134                    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            // Remove redundant `..` components from the destination path to avoid creating
214            // directories we may not need at the destination.  E.g. a destination path of
215            //
216            //   foo/../bar
217            //
218            // Should only create the directory `bar`, not also the directory `foo`.
219            let dst_path = normalize_path(&dir.dst)
220                .with_context(|| format!("Normalizing {} failed", dir.dst.display()))?;
221
222            // Check whether any parent directories need to be made as part of this iteration of the
223            // cut.
224            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        // Emit warnings for packages that were not found
240        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        //  Check for conflicts in the resulting plan
251        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    /// Copy the packages according to this plan.  On success, all the packages will be copied to
272    /// their destinations, and their dependencies will be fixed up.  On failure, pending changes
273    /// are rolled back.
274    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        // Update the workspace at the end, so that if there is any problem before that, rollback
292        // will leave the state clean.
293        self.update_workspace()
294            .context("Failed to update [workspace].")
295    }
296
297    /// Copy the contents of `package` from its `src_path` to its `dst_path`, unchanged.
298    fn copy_package(&self, package: &CutPackage) -> Result<()> {
299        // Copy everything in the directory as-is, except for any "target" directories
300        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    /// Fix the contents of the copied package's `Cargo.toml`: name altered to match
308    /// `package.dst_name` and local relative-path-based dependencies are updated to account for the
309    /// copied package's new location.  Assumes that all copied files exist (but may not contain
310    /// up-to-date information).
311    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        // Update the package name
316        toml["package"]["name"] = toml_edit::value(&package.dst_name);
317
318        // Fix-up references to any kind of dependency (dependencies, dev-dependencies,
319        // build-dependencies, target-specific dependencies).
320        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    /// Find all dependency tables in `table`, part of a manifest at `dst_path/Cargo.toml`
335    /// (originally at `src_path/Cargo.toml`), and fix (relative) paths to account for the change in
336    /// the package's location.
337    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    /// Update an individual dependency from a copied package manifest.  Only local path-based
357    /// dependencies are updated:
358    ///
359    ///     Dep = { path = "..." }
360    ///
361    /// If `Dep` is another package to be copied as part of this plan, the path is updated to the
362    /// location it is copied to.  Otherwise, its location (a relative path) is updated to account
363    /// for the fact that the copied package is at a new location.
364    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        // If the dep has an explicit package name, use that as the key for finding package
376        // information, rather than the field name of the dep.
377        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        // Only path-based dependencies need to be updated.
384        let Some(path) = dep.get_mut("path") else {
385            return Ok(());
386        };
387
388        if let Some(dep_pkg) = dep_pkg {
389            // Dependency is for a package that was cut, redirect to the cut package.
390            *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            // Dependency is for an existing (non-cut) local package, fix up its (relative) path to
396            // now be relative to its cut location.
397            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    /// Add entries to the `members` and `exclude` arrays in the root manifest's `workspace` table.
405    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                    // This assumes that there is a "workspace.members" section, which is a fair
420                    // assumption in our repo.
421                    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                    // This assumes that there is a "workspace.exclude" section, which is a fair
431                    // assumption in our repo.
432                    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    /// Attempt to clean-up the partial results of executing a plan, by deleting the directories
463    /// that the plan would have created.  Swallows and prints errors to make sure as much clean-up
464    /// as possible is done -- this function is typically called when some other error has occurred,
465    /// so it's unclear what it's starting state would be.
466    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    /// Read `members` and `exclude` from the `workspace` section of the `Cargo.toml` file in
477    /// directory `root`.  Fails if there isn't a manifest, it doesn't contain a `workspace`
478    /// section, or the relevant fields are not formatted as expected.
479    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    /// Determine the state of the path insofar as whether it is a direct member or exclude of this
499    /// `Workspace`.
500    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
561/// Find the root of the git repository containing `cwd`, if it exists, return `None` otherwise.
562/// This function only searches prefixes of the provided path for the git repo, so if the path is
563/// given as a relative path within the repository, the root will not be found.
564fn 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
580/// Read `[field]` from `table`, as an array of strings, and interpret as a set of paths,
581/// canonicalized relative to a `root` path.
582///
583/// Fails if the field does not exist, does not consist of all strings, or if a path fails to
584/// canonicalize.
585fn 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
613/// Represent `path` as a TOML value, by first describing it as a relative path (relative to
614/// `root`), and then converting it to a String.  Fails if either `root` or `path` are not real
615/// paths (cannot be canonicalized), or the resulting relative path cannot be represented as a
616/// String.
617fn 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
630/// Format a TOML array of strings: Splits elements over multiple lines, indents them, sorts them,
631/// and adds a trailing comma.
632fn 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        // This crate is a member of the workspace
727        assert!(ws.members.contains(&cut));
728
729        // Other examples
730        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        // Create a plan where all the new packages are gathered into a single top-level destination
902        // directory, and expect that the resulting plan's `directories` only contains one entry.
903        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    /// Print with pretty-printed debug formatting, with repo paths scrubbed out for consistency.
1378    fn debug_for_test<T: fmt::Debug>(x: &T) -> String {
1379        scrub_path(&format!("{x:#?}"), repo_root())
1380    }
1381
1382    /// Print with display formatting, with repo paths scrubbed out for consistency.
1383    fn display_for_test<T: fmt::Display>(x: &T) -> String {
1384        scrub_path(&format!("{x}"), repo_root())
1385    }
1386
1387    /// Read multiple files into one string.
1388    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}