1use 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 #[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 {abs_dest} not to exist"
170 );
171 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 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 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 let new_path = match self {
237 DestDir::Exists(dest_dir) => {
238 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 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 new_path.starts_with(src_dir) {
256 bail!("invalid move: {} -> {}", src_dir, new_path);
257 }
258
259 Ok(new_path)
260 }
261}
262
263fn 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 let workspace = pkg_graph.workspace();
298 let workspace_root = workspace.root();
299 let _package = workspace.member_by_path(src_dir)?;
301
302 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 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 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 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 Vec::new()
373 }
374 };
375
376 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 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 §ion_name in SECTION_NAMES {
412 let section = &mut table[section_name];
413 let section_table = match section {
414 Item::None => {
415 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 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 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 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 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 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 let member_dir = rel_path(&abs_member_dir, workspace_root)?;
496
497 if let Some(package_move) = src_moves.get(member_dir) {
498 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
521fn 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 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}