1use 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 #[clap(name = "DIR", required = true)]
24 src_dirs: Vec<Utf8PathBuf>,
25
26 #[clap(name = "DEST")]
28 dest_dir: Utf8PathBuf,
29
30 #[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 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 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 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 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 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 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 for (manifest_path, edits) in &manifest_edits {
146 apply_edits(manifest_path, edits)?;
147 }
148
149 update_root_toml(workspace_root, &src_moves)
152 .wrap_err_with(|| eyre!("error while updating root toml at {}", workspace_root))?;
153
154 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 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(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 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 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 let new_path = match self {
238 DestDir::Exists(dest_dir) => {
239 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 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 new_path.starts_with(src_dir) {
257 bail!("invalid move: {} -> {}", src_dir, new_path);
258 }
259
260 Ok(new_path)
261 }
262}
263
264fn 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 let workspace = pkg_graph.workspace();
299 let workspace_root = workspace.root();
300 let _package = workspace.member_by_path(src_dir)?;
302
303 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 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 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 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 Vec::new()
374 }
375 };
376
377 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 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 §ion_name in SECTION_NAMES {
413 let section = &mut table[section_name];
414 let section_table = match section {
415 Item::None => {
416 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 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 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 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 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 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 let member_dir = rel_path(&abs_member_dir, workspace_root)?;
497
498 if let Some(package_move) = src_moves.get(member_dir) {
499 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
522fn 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 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}