cut/
plan.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use 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/// Description of where packages should be copied to, what their new names
22/// should be, and whether they should be added to the `workspace` `members` or
23/// `exclude` fields.
24#[derive(Debug)]
25pub(crate) struct CutPlan {
26    /// Root of the repository, where the `Cargo.toml` containing the
27    /// `workspace` configuration is found.
28    root: PathBuf,
29
30    /// New directories that need to be created.  Used to clean-up copied
31    /// packages on roll-back.  If multiple nested directories must be
32    /// created, only contains their shortest common prefix.
33    directories: BTreeSet<PathBuf>,
34
35    /// Mapping from the names of existing packages to be cut, to the details of
36    /// where they will be copied to.
37    packages: BTreeMap<String, CutPackage>,
38}
39
40/// Details for an individual copied package in the feature being cut.
41#[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/// Whether the package in question is an explicit member of the workspace, an
50/// explicit exclude of the workspace, or neither (in which case it could still
51/// transitively be one or the other).
52#[derive(Debug, PartialEq, Eq)]
53pub(crate) enum WorkspaceState {
54    Member,
55    Exclude,
56    Unknown,
57}
58
59/// Relevant contents of a Cargo.toml `workspace` section.
60#[derive(Debug)]
61struct Workspace {
62    /// Canonicalized paths of workspace members
63    members: HashSet<PathBuf>,
64    /// Canonicalized paths of workspace excludes
65    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    /// Scan `args.directories` looking for `args.packages` to produce a new
97    /// plan.  The resulting plan is guaranteed not to contain any duplicate
98    /// packages (by name or path), or overwrite any existing packages.
99    /// Returns an error if it's not possible to construct such a plan.
100    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                // Figure out whether the parent directory was already created, or whether this
129                // directory needs to be created.
130                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                    // Skip `target` directories.
142                    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            // Remove redundant `..` components from the destination path to avoid creating
222            // directories we may not need at the destination.  E.g. a destination path of
223            //
224            //   foo/../bar
225            //
226            // Should only create the directory `bar`, not also the directory `foo`.
227            let dst_path = normalize_path(&dir.dst)
228                .with_context(|| format!("Normalizing {} failed", dir.dst.display()))?;
229
230            // Check whether any parent directories need to be made as part of this
231            // iteration of the cut.
232            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        // Emit warnings for packages that were not found
248        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        //  Check for conflicts in the resulting plan
259        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    /// Copy the packages according to this plan.  On success, all the packages
280    /// will be copied to their destinations, and their dependencies will be
281    /// fixed up.  On failure, pending changes are rolled back.
282    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        // Update the workspace at the end, so that if there is any problem before that,
300        // rollback will leave the state clean.
301        self.update_workspace()
302            .context("Failed to update [workspace].")
303    }
304
305    /// Copy the contents of `package` from its `src_path` to its `dst_path`,
306    /// unchanged.
307    fn copy_package(&self, package: &CutPackage) -> Result<()> {
308        // Copy everything in the directory as-is, except for any "target" directories
309        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    /// Fix the contents of the copied package's `Cargo.toml`: name altered to
317    /// match `package.dst_name` and local relative-path-based dependencies
318    /// are updated to account for the copied package's new location.
319    /// Assumes that all copied files exist (but may not contain up-to-date
320    /// information).
321    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        // Update the package name
326        toml["package"]["name"] = toml_edit::value(&package.dst_name);
327
328        // Fix-up references to any kind of dependency (dependencies, dev-dependencies,
329        // build-dependencies, target-specific dependencies).
330        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    /// Find all dependency tables in `table`, part of a manifest at
345    /// `dst_path/Cargo.toml` (originally at `src_path/Cargo.toml`), and fix
346    /// (relative) paths to account for the change in the package's
347    /// location.
348    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    /// Update an individual dependency from a copied package manifest.  Only
368    /// local path-based dependencies are updated:
369    ///
370    ///     Dep = { path = "..." }
371    ///
372    /// If `Dep` is another package to be copied as part of this plan, the path
373    /// is updated to the location it is copied to.  Otherwise, its location
374    /// (a relative path) is updated to account for the fact that the copied
375    /// package is at a new location.
376    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        // If the dep has an explicit package name, use that as the key for finding
388        // package information, rather than the field name of the dep.
389        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        // Only path-based dependencies need to be updated.
396        let Some(path) = dep.get_mut("path") else {
397            return Ok(());
398        };
399
400        if let Some(dep_pkg) = dep_pkg {
401            // Dependency is for a package that was cut, redirect to the cut package.
402            *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            // Dependency is for an existing (non-cut) local package, fix up its (relative)
408            // path to now be relative to its cut location.
409            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    /// Add entries to the `members` and `exclude` arrays in the root manifest's
417    /// `workspace` table.
418    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                    // This assumes that there is a "workspace.members" section, which is a fair
433                    // assumption in our repo.
434                    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                    // This assumes that there is a "workspace.exclude" section, which is a fair
444                    // assumption in our repo.
445                    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    /// Attempt to clean-up the partial results of executing a plan, by deleting
476    /// the directories that the plan would have created.  Swallows and
477    /// prints errors to make sure as much clean-up as possible is done --
478    /// this function is typically called when some other error has occurred,
479    /// so it's unclear what it's starting state would be.
480    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    /// Read `members` and `exclude` from the `workspace` section of the
491    /// `Cargo.toml` file in directory `root`.  Fails if there isn't a
492    /// manifest, it doesn't contain a `workspace` section, or the relevant
493    /// fields are not formatted as expected.
494    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    /// Determine the state of the path insofar as whether it is a direct member
514    /// or exclude of this `Workspace`.
515    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
576/// Find the root of the git repository containing `cwd`, if it exists, return
577/// `None` otherwise. This function only searches prefixes of the provided path
578/// for the git repo, so if the path is given as a relative path within the
579/// repository, the root will not be found.
580fn 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
596/// Read `[field]` from `table`, as an array of strings, and interpret as a set
597/// of paths, canonicalized relative to a `root` path.
598///
599/// Fails if the field does not exist, does not consist of all strings, or if a
600/// path fails to canonicalize.
601fn 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
629/// Represent `path` as a TOML value, by first describing it as a relative path
630/// (relative to `root`), and then converting it to a String.  Fails if either
631/// `root` or `path` are not real paths (cannot be canonicalized), or the
632/// resulting relative path cannot be represented as a String.
633fn 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
646/// Format a TOML array of strings: Splits elements over multiple lines, indents
647/// them, sorts them, and adds a trailing comma.
648fn 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        // This crate is a member of the workspace
741        assert!(ws.members.contains(&cut));
742
743        // Other examples
744        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        // Create a plan where all the new packages are gathered into a single top-level
916        // destination directory, and expect that the resulting plan's
917        // `directories` only contains one entry.
918        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    /// Print with pretty-printed debug formatting, with repo paths scrubbed out
1393    /// for consistency.
1394    fn debug_for_test<T: fmt::Debug>(x: &T) -> String {
1395        scrub_path(&format!("{x:#?}"), repo_root())
1396    }
1397
1398    /// Print with display formatting, with repo paths scrubbed out for
1399    /// consistency.
1400    fn display_for_test<T: fmt::Display>(x: &T) -> String {
1401        scrub_path(&format!("{x}"), repo_root())
1402    }
1403
1404    /// Read multiple files into one string.
1405    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}