cargo_guppy/
mv.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use camino::{Utf8Path, Utf8PathBuf};
5use clap::Parser;
6use color_eyre::eyre::{Result, WrapErr, bail, eyre};
7use dialoguer::Confirm;
8use guppy::graph::{PackageGraph, PackageLink, PackageMetadata};
9use guppy_cmdlib::CargoMetadataOptions;
10use pathdiff::diff_utf8_paths;
11use std::{
12    collections::{BTreeMap, HashSet, btree_map::Entry},
13    fmt, fs,
14    io::{self, Write},
15    mem,
16    path::{MAIN_SEPARATOR, Path},
17};
18use toml_edit::{DocumentMut, Item, Table, Value};
19
20#[derive(Debug, Parser)]
21pub struct MvOptions {
22    /// Source directories to move
23    #[clap(name = "DIR", required = true)]
24    src_dirs: Vec<Utf8PathBuf>,
25
26    /// Destination directory to move to
27    #[clap(name = "DEST")]
28    dest_dir: Utf8PathBuf,
29
30    /// Print out operations instead of performing them
31    #[clap(long)]
32    dry_run: bool,
33
34    #[clap(flatten)]
35    metadata_opts: CargoMetadataOptions,
36}
37
38impl MvOptions {
39    pub fn exec(&self) -> Result<()> {
40        // Construct a package graph.
41        let command = self.metadata_opts.make_command();
42        let pkg_graph = command.build_graph()?;
43        let workspace_root = pkg_graph.workspace().root();
44
45        let dest_dir = DestDir::new(&pkg_graph, &self.dest_dir)?;
46
47        if dest_dir.is_create() && self.src_dirs.len() > 1 {
48            bail!("multiple sources specified with a destination that doesn't exist");
49        }
50
51        // Each source directory maps to one or more packages.
52        let mut src_moves = BTreeMap::new();
53        for src_dir in &self.src_dirs {
54            let src_dir = canonicalize_dir(&pkg_graph, src_dir)?;
55            for (workspace_path, package_move) in moves_for(&pkg_graph, &src_dir, &dest_dir)? {
56                match src_moves.entry(workspace_path) {
57                    // This disallows, e.g. "cargo guppy mv foo foo/bar dest"
58                    Entry::Occupied(_) => bail!(
59                        "workspace path '{}' specified multiple times in source",
60                        workspace_path
61                    ),
62                    Entry::Vacant(v) => {
63                        v.insert(package_move);
64                    }
65                }
66            }
67        }
68
69        // Build a map of edits to perform (manifest path to a list of edits).
70        let mut manifest_edits: BTreeMap<&Utf8Path, Vec<_>> = BTreeMap::new();
71
72        for package_move in src_moves.values() {
73            for link in package_move.package.direct_links() {
74                let (from, to) = link.endpoints();
75                let old_path = if let Some(path) = to.source().workspace_path() {
76                    path
77                } else {
78                    continue;
79                };
80
81                // If the 'to' moves as well, let the below loop deal with it.
82                if src_moves.contains_key(old_path) {
83                    continue;
84                }
85
86                let edit_path = diff_utf8_paths(old_path, &package_move.new_path)
87                    .expect("paths are all relative so diff_paths can never return None");
88
89                manifest_edits
90                    .entry(from.manifest_path())
91                    .or_default()
92                    .push(ManifestEdit { link, edit_path });
93            }
94
95            for link in package_move.package.reverse_direct_links() {
96                let from = link.from();
97                let old_path = from
98                    .source()
99                    .workspace_path()
100                    .expect("reverse deps of workspace packages must be in workspace");
101                // If the 'from' moves as well, compute the new path based on that.
102                let edit_path = if let Some(from_move) = src_moves.get(old_path) {
103                    diff_utf8_paths(&package_move.new_path, &from_move.new_path)
104                } else {
105                    diff_utf8_paths(&package_move.new_path, old_path)
106                }
107                .expect("paths are all relative so diff_paths can never return None");
108
109                manifest_edits
110                    .entry(from.manifest_path())
111                    .or_default()
112                    .push(ManifestEdit { link, edit_path });
113            }
114        }
115
116        println!("Will perform edits:");
117        for (manifest_path, edits) in &manifest_edits {
118            println!(
119                "manifest: {}",
120                diff_utf8_paths(manifest_path, workspace_root).unwrap()
121            );
122            for edit in edits {
123                println!("  * {edit}");
124            }
125        }
126
127        println!("\nMoves:");
128        for (src_dir, package_move) in &src_moves {
129            println!("  * move {} to {}", src_dir, package_move.new_path,);
130        }
131
132        println!();
133
134        if self.dry_run {
135            return Ok(());
136        }
137
138        let perform = Confirm::new()
139            .with_prompt("Continue?")
140            .show_default(true)
141            .interact()?;
142
143        if perform {
144            // First perform the edits so that manifest paths are still valid.
145            for (manifest_path, edits) in &manifest_edits {
146                apply_edits(manifest_path, edits)?;
147            }
148
149            // Next, update the root manifest. Do this before moving directories because this relies
150            // on the old directories existing.
151            update_root_toml(workspace_root, &src_moves)
152                .wrap_err_with(|| eyre!("error while updating root toml at {}", workspace_root))?;
153
154            // Finally, move directories into their new spots.
155            // Rely on the fact that BTreeMap is sorted so that "foo" always shows up before
156            // "foo/bar".
157            // TODO: this would be better modeled as a trie.
158            let mut done = HashSet::new();
159            for (src_dir, package_move) in &src_moves {
160                if src_dir.ancestors().any(|ancestor| done.contains(&ancestor)) {
161                    // If we need to move both foo and foo/bar, and foo has already been moved,
162                    // skip foo/bar.
163                    continue;
164                }
165                let abs_src = workspace_root.join(src_dir);
166                let abs_dest = workspace_root.join(&package_move.new_path);
167                assert!(
168                    !abs_dest.exists(),
169                    "expected destination {abs_dest} not to exist"
170                );
171                // fs::rename behaves differently on Unix and Windows if the destination exists.
172                // But we don't expect it to, as the assertion above checks.
173                fs::rename(abs_src, &abs_dest).wrap_err_with(|| {
174                    eyre!("renaming {} to {} failed", src_dir, package_move.new_path)
175                })?;
176                done.insert(src_dir);
177            }
178        }
179
180        Ok(())
181    }
182}
183
184enum DestDir {
185    Exists(Utf8PathBuf),
186    Create(Utf8PathBuf),
187}
188
189impl DestDir {
190    fn new(pkg_graph: &PackageGraph, dest_dir: &Utf8Path) -> Result<Self> {
191        let workspace = pkg_graph.workspace();
192        let workspace_root = workspace.root();
193
194        match dest_dir.canonicalize() {
195            Ok(dest_dir) => {
196                if !dest_dir.is_dir() {
197                    bail!("destination {} is not a directory", dest_dir.display());
198                }
199
200                // The destination directory exists.
201                Ok(DestDir::Exists(
202                    rel_path(&dest_dir, workspace_root)?.to_path_buf(),
203                ))
204            }
205            Err(err) if err.kind() == io::ErrorKind::NotFound => {
206                // The destination directory doesn't exist and needs to be created.
207                // Canonicalize the parent, then glue the last component to it.
208                let last_component = dest_dir
209                    .file_name()
210                    .ok_or_else(|| eyre!("destination {} cannot end with ..", dest_dir))?;
211                let parent = dest_dir
212                    .parent()
213                    .ok_or_else(|| eyre!("destination {} cannot end with ..", dest_dir))?;
214                let parent = if parent.as_os_str() == "" {
215                    Utf8Path::new(".")
216                } else {
217                    parent
218                };
219
220                let parent = canonicalize_dir(pkg_graph, parent)?;
221                Ok(DestDir::Create(parent.join(last_component)))
222            }
223            Err(err) => Err(err).wrap_err_with(|| eyre!("reading destination {} failed", dest_dir)),
224        }
225    }
226
227    fn is_create(&self) -> bool {
228        match self {
229            DestDir::Create(_) => true,
230            DestDir::Exists(_) => false,
231        }
232    }
233
234    fn join(&self, workspace_path: &Utf8Path, src_dir: &Utf8Path) -> Result<Utf8PathBuf> {
235        // Consider e.g. workspace path = foo/bar, src dir = foo, dest dir = quux.
236        let new_path = match self {
237            DestDir::Exists(dest_dir) => {
238                // quux exists, so the new path would be quux/foo/bar, not quux/bar. So look at the
239                // src dir's parent.
240                let trailing = workspace_path
241                    .strip_prefix(src_dir.parent().expect("src dir should have a parent"))
242                    .expect("workspace path is inside src dir");
243                dest_dir.join(trailing)
244            }
245            DestDir::Create(dest_dir) => {
246                // quux does not exist, so the new path would be quux/bar.
247                let trailing = workspace_path
248                    .strip_prefix(src_dir)
249                    .expect("workspace path is inside src dir");
250                dest_dir.join(trailing)
251            }
252        };
253
254        // If the new path is inside (or the same as) the source directory, it's a problem.
255        if new_path.starts_with(src_dir) {
256            bail!("invalid move: {} -> {}", src_dir, new_path);
257        }
258
259        Ok(new_path)
260    }
261}
262
263/// Return the workspace path for a given directory (relative to cwd).
264fn canonicalize_dir(pkg_graph: &PackageGraph, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {
265    let workspace = pkg_graph.workspace();
266    let workspace_root = workspace.root();
267
268    let path = path.as_ref();
269    let canonical_path = path
270        .canonicalize()
271        .wrap_err_with(|| eyre!("reading path {} failed", path))?;
272    if !canonical_path.is_dir() {
273        bail!("path {} is not a directory", canonical_path.display());
274    }
275
276    Ok(rel_path(&canonical_path, workspace_root)?.to_path_buf())
277}
278
279fn rel_path<'a>(path: &'a Path, workspace_root: &Utf8Path) -> Result<&'a Utf8Path> {
280    let rel_path = path.strip_prefix(workspace_root).wrap_err_with(|| {
281        eyre!(
282            "path {} not in workspace root {}",
283            path.display(),
284            workspace_root
285        )
286    })?;
287    Utf8Path::from_path(rel_path)
288        .ok_or_else(|| eyre!("rel path {} is invalid UTF-8", rel_path.display()))
289}
290
291fn moves_for<'g: 'a, 'a>(
292    pkg_graph: &'g PackageGraph,
293    src_dir: &'a Utf8Path,
294    dest_dir: &'a DestDir,
295) -> Result<Vec<(&'g Utf8Path, PackageMove<'g>)>> {
296    // TODO: speed this up using a trie in guppy? Probably not that important.
297    let workspace = pkg_graph.workspace();
298    let workspace_root = workspace.root();
299    // Ensure that the path refers to a package.
300    let _package = workspace.member_by_path(src_dir)?;
301
302    // Now look for all paths that start with the package.
303    workspace
304        .iter_by_path()
305        .filter_map(move |(workspace_path, package)| {
306            if workspace_path.starts_with(src_dir) {
307                let pair = dest_dir.join(workspace_path, src_dir).and_then(|new_path| {
308                    // Check that the new path doesn't exist already.
309                    let abs_new_path = workspace_root.join(&new_path);
310                    if abs_new_path.exists() {
311                        bail!(
312                            "attempted to move {} to {}, which already exists",
313                            workspace_path,
314                            new_path
315                        );
316                    }
317
318                    // new_path can sometimes have a trailing slash -- remove it if it does.
319                    let mut new_path = new_path.into_string();
320                    if new_path.ends_with(MAIN_SEPARATOR) {
321                        new_path.pop();
322                    }
323                    let new_path = new_path.into();
324
325                    Ok((workspace_path, PackageMove { package, new_path }))
326                });
327                Some(pair)
328            } else {
329                None
330            }
331        })
332        .collect()
333}
334
335#[derive(Clone, Debug)]
336struct PackageMove<'g> {
337    package: PackageMetadata<'g>,
338    new_path: Utf8PathBuf,
339}
340
341#[derive(Clone, Debug)]
342struct ManifestEdit<'g> {
343    link: PackageLink<'g>,
344    edit_path: Utf8PathBuf,
345}
346
347impl fmt::Display for ManifestEdit<'_> {
348    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
349        write!(
350            f,
351            "update {} to path {}",
352            self.link.dep_name(),
353            self.edit_path,
354        )
355    }
356}
357
358fn apply_edits(manifest_path: &Utf8Path, edits: &[ManifestEdit<'_>]) -> Result<()> {
359    let mut document = read_toml(manifest_path)?;
360    let table = document.as_table_mut();
361
362    // Grab the list of target specs.
363    let all_targets = match table.get("target") {
364        Some(target_section) => match target_section.as_table() {
365            Some(table) => table.iter().map(|(target, _)| target.to_string()).collect(),
366            None => {
367                bail!("in {}, [target] is not a table", manifest_path);
368            }
369        },
370        None => {
371            // There's no 'target' section in the manifest.
372            Vec::new()
373        }
374    };
375
376    // Search through:
377    // * dependencies, build-dependencies, dev-dependencies
378    // * [target.'foo'.dependencies], .build-dependencies and .dev-dependencies
379    for edit in edits {
380        apply_edit(table, edit)
381            .wrap_err_with(|| eyre!("error while applying edits to {}", manifest_path))?;
382        for target in &all_targets {
383            let target_table = match &mut table["target"][target] {
384                Item::Table(target_table) => target_table,
385                _ => {
386                    // Not a table, skip it.
387                    continue;
388                }
389            };
390            apply_edit(target_table, edit).wrap_err_with(|| {
391                eyre!(
392                    "error while applying edits to {}, section [target.'{}']",
393                    manifest_path,
394                    target
395                )
396            })?;
397        }
398    }
399
400    fs::write(manifest_path, document.to_string())
401        .wrap_err_with(|| eyre!("error while writing manifest {}", manifest_path))?;
402
403    Ok(())
404}
405
406fn apply_edit(table: &mut Table, edit: &ManifestEdit<'_>) -> Result<()> {
407    static SECTION_NAMES: &[&str] = &["dependencies", "build-dependencies", "dev-dependencies"];
408
409    let dep_name = edit.link.dep_name();
410
411    for &section_name in SECTION_NAMES {
412        let section = &mut table[section_name];
413        let section_table = match section {
414            Item::None => {
415                // This section is empty -- skip it.
416                continue;
417            }
418            Item::Table(table) => table,
419            Item::Value(_) | Item::ArrayOfTables(_) => {
420                bail!("section [{}] is not a table", section_name);
421            }
422        };
423
424        match section_table.get_mut(dep_name) {
425            Some(item) => {
426                let dep_table = item.as_table_like_mut().ok_or_else(|| {
427                    eyre!("in section [{}], {} is not a table", section_name, dep_name)
428                })?;
429                // The dep table should have a path entry.
430                match dep_table.get_mut("path") {
431                    Some(Item::Value(value)) => {
432                        replace_decorated(value, edit.edit_path.as_str());
433                    }
434                    _ => {
435                        bail!(
436                            "in section [{}], {}.path is not a string",
437                            section_name,
438                            dep_name
439                        );
440                    }
441                }
442            }
443            None => continue,
444        }
445    }
446
447    Ok(())
448}
449
450fn update_root_toml(
451    workspace_root: &Utf8Path,
452    src_moves: &BTreeMap<&Utf8Path, PackageMove<'_>>,
453) -> Result<()> {
454    let root_manifest_path = workspace_root.join("Cargo.toml");
455    let mut document = read_toml(&root_manifest_path)?;
456
457    // Fix up paths in workspace.members or workspace.default-members.
458    let workspace_table = match document.as_table_mut().get_mut("workspace") {
459        Some(item) => item
460            .as_table_mut()
461            .ok_or_else(|| eyre!("[workspace] is not a table"))?,
462        None => {
463            // No [workspace] section -- possibly a single-crate manifest?
464            return Ok(());
465        }
466    };
467
468    static TO_UPDATE: &[&str] = &["members", "default-members"];
469
470    for to_update in TO_UPDATE {
471        let members = match workspace_table.get_mut(to_update) {
472            Some(item) => item
473                .as_array_mut()
474                .ok_or_else(|| eyre!("in [workspace], {} is not an array", to_update))?,
475            None => {
476                // default-members may not always exist.
477                continue;
478            }
479        };
480
481        for idx in 0..members.len() {
482            let member = members.get(idx).expect("valid idx");
483            match member.as_str() {
484                Some(path) => {
485                    let abs_member_dir = workspace_root.join(path);
486                    // The workspace path saved in the TOML may not be in canonical form.
487                    let abs_member_dir = abs_member_dir.canonicalize().wrap_err_with(|| {
488                        eyre!(
489                            "in [workspace] {}, error while canonicalizing path {}",
490                            to_update,
491                            path
492                        )
493                    })?;
494                    // member dir is the canonical dir relative to the root.
495                    let member_dir = rel_path(&abs_member_dir, workspace_root)?;
496
497                    if let Some(package_move) = src_moves.get(member_dir) {
498                        // This path was moved.
499                        members.replace(idx, package_move.new_path.as_str());
500                    }
501                }
502                None => bail!("in [workspace], {} contains non-strings", to_update),
503            }
504        }
505    }
506
507    let mut out = fs::File::create(&root_manifest_path)
508        .wrap_err_with(|| eyre!("Error while opening {}", root_manifest_path))?;
509    write!(out, "{document}")?;
510
511    Ok(())
512}
513
514fn read_toml(manifest_path: &Utf8Path) -> Result<DocumentMut> {
515    let toml = fs::read_to_string(manifest_path)
516        .wrap_err_with(|| eyre!("error while reading manifest {}", manifest_path))?;
517    toml.parse::<DocumentMut>()
518        .wrap_err_with(|| eyre!("error while parsing manifest {}", manifest_path))
519}
520
521/// Replace the value while retaining the decor.
522fn replace_decorated(dest: &mut Value, new_value: impl Into<Value>) -> Value {
523    let decor = dest.decor();
524    let mut new_value = new_value.into();
525    // Copy over the decor from dest into new_value.
526    let new_decor = new_value.decor_mut();
527    if let Some(prefix) = decor.prefix() {
528        new_decor.set_prefix(prefix.clone());
529    }
530    if let Some(suffix) = decor.suffix() {
531        new_decor.set_suffix(suffix.clone());
532    }
533
534    mem::replace(dest, new_value)
535}