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::{bail, eyre, Result, WrapErr};
7use dialoguer::Confirm;
8use guppy::graph::{PackageGraph, PackageLink, PackageMetadata};
9use guppy_cmdlib::CargoMetadataOptions;
10use pathdiff::diff_utf8_paths;
11use std::{
12    collections::{btree_map::Entry, BTreeMap, HashSet},
13    fmt, fs,
14    io::{self, Write},
15    mem,
16    path::{Path, MAIN_SEPARATOR},
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 {} not to exist",
170                    abs_dest
171                );
172                // fs::rename behaves differently on Unix and Windows if the destination exists.
173                // But we don't expect it to, as the assertion above checks.
174                fs::rename(abs_src, &abs_dest).wrap_err_with(|| {
175                    eyre!("renaming {} to {} failed", src_dir, package_move.new_path)
176                })?;
177                done.insert(src_dir);
178            }
179        }
180
181        Ok(())
182    }
183}
184
185enum DestDir {
186    Exists(Utf8PathBuf),
187    Create(Utf8PathBuf),
188}
189
190impl DestDir {
191    fn new(pkg_graph: &PackageGraph, dest_dir: &Utf8Path) -> Result<Self> {
192        let workspace = pkg_graph.workspace();
193        let workspace_root = workspace.root();
194
195        match dest_dir.canonicalize() {
196            Ok(dest_dir) => {
197                if !dest_dir.is_dir() {
198                    bail!("destination {} is not a directory", dest_dir.display());
199                }
200
201                // The destination directory exists.
202                Ok(DestDir::Exists(
203                    rel_path(&dest_dir, workspace_root)?.to_path_buf(),
204                ))
205            }
206            Err(err) if err.kind() == io::ErrorKind::NotFound => {
207                // The destination directory doesn't exist and needs to be created.
208                // Canonicalize the parent, then glue the last component to it.
209                let last_component = dest_dir
210                    .file_name()
211                    .ok_or_else(|| eyre!("destination {} cannot end with ..", dest_dir))?;
212                let parent = dest_dir
213                    .parent()
214                    .ok_or_else(|| eyre!("destination {} cannot end with ..", dest_dir))?;
215                let parent = if parent.as_os_str() == "" {
216                    Utf8Path::new(".")
217                } else {
218                    parent
219                };
220
221                let parent = canonicalize_dir(pkg_graph, parent)?;
222                Ok(DestDir::Create(parent.join(last_component)))
223            }
224            Err(err) => Err(err).wrap_err_with(|| eyre!("reading destination {} failed", dest_dir)),
225        }
226    }
227
228    fn is_create(&self) -> bool {
229        match self {
230            DestDir::Create(_) => true,
231            DestDir::Exists(_) => false,
232        }
233    }
234
235    fn join(&self, workspace_path: &Utf8Path, src_dir: &Utf8Path) -> Result<Utf8PathBuf> {
236        // Consider e.g. workspace path = foo/bar, src dir = foo, dest dir = quux.
237        let new_path = match self {
238            DestDir::Exists(dest_dir) => {
239                // quux exists, so the new path would be quux/foo/bar, not quux/bar. So look at the
240                // src dir's parent.
241                let trailing = workspace_path
242                    .strip_prefix(src_dir.parent().expect("src dir should have a parent"))
243                    .expect("workspace path is inside src dir");
244                dest_dir.join(trailing)
245            }
246            DestDir::Create(dest_dir) => {
247                // quux does not exist, so the new path would be quux/bar.
248                let trailing = workspace_path
249                    .strip_prefix(src_dir)
250                    .expect("workspace path is inside src dir");
251                dest_dir.join(trailing)
252            }
253        };
254
255        // If the new path is inside (or the same as) the source directory, it's a problem.
256        if new_path.starts_with(src_dir) {
257            bail!("invalid move: {} -> {}", src_dir, new_path);
258        }
259
260        Ok(new_path)
261    }
262}
263
264/// Return the workspace path for a given directory (relative to cwd).
265fn canonicalize_dir(pkg_graph: &PackageGraph, path: impl AsRef<Utf8Path>) -> Result<Utf8PathBuf> {
266    let workspace = pkg_graph.workspace();
267    let workspace_root = workspace.root();
268
269    let path = path.as_ref();
270    let canonical_path = path
271        .canonicalize()
272        .wrap_err_with(|| eyre!("reading path {} failed", path))?;
273    if !canonical_path.is_dir() {
274        bail!("path {} is not a directory", canonical_path.display());
275    }
276
277    Ok(rel_path(&canonical_path, workspace_root)?.to_path_buf())
278}
279
280fn rel_path<'a>(path: &'a Path, workspace_root: &Utf8Path) -> Result<&'a Utf8Path> {
281    let rel_path = path.strip_prefix(workspace_root).wrap_err_with(|| {
282        eyre!(
283            "path {} not in workspace root {}",
284            path.display(),
285            workspace_root
286        )
287    })?;
288    Utf8Path::from_path(rel_path)
289        .ok_or_else(|| eyre!("rel path {} is invalid UTF-8", rel_path.display()))
290}
291
292fn moves_for<'g: 'a, 'a>(
293    pkg_graph: &'g PackageGraph,
294    src_dir: &'a Utf8Path,
295    dest_dir: &'a DestDir,
296) -> Result<Vec<(&'g Utf8Path, PackageMove<'g>)>> {
297    // TODO: speed this up using a trie in guppy? Probably not that important.
298    let workspace = pkg_graph.workspace();
299    let workspace_root = workspace.root();
300    // Ensure that the path refers to a package.
301    let _package = workspace.member_by_path(src_dir)?;
302
303    // Now look for all paths that start with the package.
304    workspace
305        .iter_by_path()
306        .filter_map(move |(workspace_path, package)| {
307            if workspace_path.starts_with(src_dir) {
308                let pair = dest_dir.join(workspace_path, src_dir).and_then(|new_path| {
309                    // Check that the new path doesn't exist already.
310                    let abs_new_path = workspace_root.join(&new_path);
311                    if abs_new_path.exists() {
312                        bail!(
313                            "attempted to move {} to {}, which already exists",
314                            workspace_path,
315                            new_path
316                        );
317                    }
318
319                    // new_path can sometimes have a trailing slash -- remove it if it does.
320                    let mut new_path = new_path.into_string();
321                    if new_path.ends_with(MAIN_SEPARATOR) {
322                        new_path.pop();
323                    }
324                    let new_path = new_path.into();
325
326                    Ok((workspace_path, PackageMove { package, new_path }))
327                });
328                Some(pair)
329            } else {
330                None
331            }
332        })
333        .collect()
334}
335
336#[derive(Clone, Debug)]
337struct PackageMove<'g> {
338    package: PackageMetadata<'g>,
339    new_path: Utf8PathBuf,
340}
341
342#[derive(Clone, Debug)]
343struct ManifestEdit<'g> {
344    link: PackageLink<'g>,
345    edit_path: Utf8PathBuf,
346}
347
348impl fmt::Display for ManifestEdit<'_> {
349    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
350        write!(
351            f,
352            "update {} to path {}",
353            self.link.dep_name(),
354            self.edit_path,
355        )
356    }
357}
358
359fn apply_edits(manifest_path: &Utf8Path, edits: &[ManifestEdit<'_>]) -> Result<()> {
360    let mut document = read_toml(manifest_path)?;
361    let table = document.as_table_mut();
362
363    // Grab the list of target specs.
364    let all_targets = match table.get("target") {
365        Some(target_section) => match target_section.as_table() {
366            Some(table) => table.iter().map(|(target, _)| target.to_string()).collect(),
367            None => {
368                bail!("in {}, [target] is not a table", manifest_path);
369            }
370        },
371        None => {
372            // There's no 'target' section in the manifest.
373            Vec::new()
374        }
375    };
376
377    // Search through:
378    // * dependencies, build-dependencies, dev-dependencies
379    // * [target.'foo'.dependencies], .build-dependencies and .dev-dependencies
380    for edit in edits {
381        apply_edit(table, edit)
382            .wrap_err_with(|| eyre!("error while applying edits to {}", manifest_path))?;
383        for target in &all_targets {
384            let target_table = match &mut table["target"][target] {
385                Item::Table(target_table) => target_table,
386                _ => {
387                    // Not a table, skip it.
388                    continue;
389                }
390            };
391            apply_edit(target_table, edit).wrap_err_with(|| {
392                eyre!(
393                    "error while applying edits to {}, section [target.'{}']",
394                    manifest_path,
395                    target
396                )
397            })?;
398        }
399    }
400
401    fs::write(manifest_path, document.to_string())
402        .wrap_err_with(|| eyre!("error while writing manifest {}", manifest_path))?;
403
404    Ok(())
405}
406
407fn apply_edit(table: &mut Table, edit: &ManifestEdit<'_>) -> Result<()> {
408    static SECTION_NAMES: &[&str] = &["dependencies", "build-dependencies", "dev-dependencies"];
409
410    let dep_name = edit.link.dep_name();
411
412    for &section_name in SECTION_NAMES {
413        let section = &mut table[section_name];
414        let section_table = match section {
415            Item::None => {
416                // This section is empty -- skip it.
417                continue;
418            }
419            Item::Table(table) => table,
420            Item::Value(_) | Item::ArrayOfTables(_) => {
421                bail!("section [{}] is not a table", section_name);
422            }
423        };
424
425        match section_table.get_mut(dep_name) {
426            Some(item) => {
427                let dep_table = item.as_table_like_mut().ok_or_else(|| {
428                    eyre!("in section [{}], {} is not a table", section_name, dep_name)
429                })?;
430                // The dep table should have a path entry.
431                match dep_table.get_mut("path") {
432                    Some(Item::Value(value)) => {
433                        replace_decorated(value, edit.edit_path.as_str());
434                    }
435                    _ => {
436                        bail!(
437                            "in section [{}], {}.path is not a string",
438                            section_name,
439                            dep_name
440                        );
441                    }
442                }
443            }
444            None => continue,
445        }
446    }
447
448    Ok(())
449}
450
451fn update_root_toml(
452    workspace_root: &Utf8Path,
453    src_moves: &BTreeMap<&Utf8Path, PackageMove<'_>>,
454) -> Result<()> {
455    let root_manifest_path = workspace_root.join("Cargo.toml");
456    let mut document = read_toml(&root_manifest_path)?;
457
458    // Fix up paths in workspace.members or workspace.default-members.
459    let workspace_table = match document.as_table_mut().get_mut("workspace") {
460        Some(item) => item
461            .as_table_mut()
462            .ok_or_else(|| eyre!("[workspace] is not a table"))?,
463        None => {
464            // No [workspace] section -- possibly a single-crate manifest?
465            return Ok(());
466        }
467    };
468
469    static TO_UPDATE: &[&str] = &["members", "default-members"];
470
471    for to_update in TO_UPDATE {
472        let members = match workspace_table.get_mut(to_update) {
473            Some(item) => item
474                .as_array_mut()
475                .ok_or_else(|| eyre!("in [workspace], {} is not an array", to_update))?,
476            None => {
477                // default-members may not always exist.
478                continue;
479            }
480        };
481
482        for idx in 0..members.len() {
483            let member = members.get(idx).expect("valid idx");
484            match member.as_str() {
485                Some(path) => {
486                    let abs_member_dir = workspace_root.join(path);
487                    // The workspace path saved in the TOML may not be in canonical form.
488                    let abs_member_dir = abs_member_dir.canonicalize().wrap_err_with(|| {
489                        eyre!(
490                            "in [workspace] {}, error while canonicalizing path {}",
491                            to_update,
492                            path
493                        )
494                    })?;
495                    // member dir is the canonical dir relative to the root.
496                    let member_dir = rel_path(&abs_member_dir, workspace_root)?;
497
498                    if let Some(package_move) = src_moves.get(member_dir) {
499                        // This path was moved.
500                        members.replace(idx, package_move.new_path.as_str());
501                    }
502                }
503                None => bail!("in [workspace], {} contains non-strings", to_update),
504            }
505        }
506    }
507
508    let mut out = fs::File::create(&root_manifest_path)
509        .wrap_err_with(|| eyre!("Error while opening {}", root_manifest_path))?;
510    write!(out, "{}", document)?;
511
512    Ok(())
513}
514
515fn read_toml(manifest_path: &Utf8Path) -> Result<DocumentMut> {
516    let toml = fs::read_to_string(manifest_path)
517        .wrap_err_with(|| eyre!("error while reading manifest {}", manifest_path))?;
518    toml.parse::<DocumentMut>()
519        .wrap_err_with(|| eyre!("error while parsing manifest {}", manifest_path))
520}
521
522/// Replace the value while retaining the decor.
523fn replace_decorated(dest: &mut Value, new_value: impl Into<Value>) -> Value {
524    let decor = dest.decor();
525    let mut new_value = new_value.into();
526    // Copy over the decor from dest into new_value.
527    let new_decor = new_value.decor_mut();
528    if let Some(prefix) = decor.prefix() {
529        new_decor.set_prefix(prefix.clone());
530    }
531    if let Some(suffix) = decor.suffix() {
532        new_decor.set_suffix(suffix.clone());
533    }
534
535    mem::replace(dest, new_value)
536}