1use crate::{
5 hakari::{DepFormatVersion, WorkspaceHackLineStyle},
6 helpers::VersionDisplay,
7};
8use atomicwrites::{AtomicFile, OverwriteBehavior};
9use camino::{Utf8Path, Utf8PathBuf};
10use guppy::{
11 graph::{DependencyDirection, PackageGraph, PackageMetadata, PackageSet},
12 Version,
13};
14use owo_colors::{OwoColorize, Style};
15use std::{borrow::Cow, cmp::Ordering, collections::BTreeMap, error, fmt, fs, io, io::Write};
16use toml_edit::{
17 Array, DocumentMut, Formatted, InlineTable, Item, Table, TableLike, TomlError, Value,
18};
19
20#[derive(Clone, Debug)]
22pub struct WorkspaceOps<'g, 'a> {
23 graph: &'g PackageGraph,
24 ops: Vec<WorkspaceOp<'g, 'a>>,
25}
26
27impl<'g, 'a> WorkspaceOps<'g, 'a> {
28 pub(crate) fn new(
29 graph: &'g PackageGraph,
30 ops: impl IntoIterator<Item = WorkspaceOp<'g, 'a>>,
31 ) -> Self {
32 Self {
33 graph,
34 ops: ops.into_iter().collect(),
35 }
36 }
37
38 #[inline]
40 pub fn display<'ops>(&'ops self) -> WorkspaceOpsDisplay<'g, 'a, 'ops> {
41 WorkspaceOpsDisplay::new(self)
42 }
43
44 #[inline]
46 pub fn is_empty(&self) -> bool {
47 self.ops.is_empty()
48 }
49
50 pub fn apply(&self) -> Result<(), ApplyError> {
54 let workspace_root = self.graph.workspace().root();
55 let canonical_workspace_root = workspace_root.canonicalize_utf8().map_err(|error| {
56 ApplyError::io(
57 "unable to canonicalize workspace root",
58 workspace_root.to_owned(),
59 error,
60 )
61 })?;
62 for op in &self.ops {
63 op.apply(&canonical_workspace_root)?;
64 }
65 Ok(())
66 }
67}
68
69#[derive(Clone, Debug)]
70pub(crate) enum WorkspaceOp<'g, 'a> {
71 NewCrate {
72 crate_path: &'a Utf8Path,
73 files: BTreeMap<Cow<'a, Utf8Path>, Cow<'a, [u8]>>,
74 root_files: BTreeMap<Cow<'a, Utf8Path>, Cow<'a, [u8]>>,
75 },
76 AddDependency {
77 name: &'a str,
78 crate_path: &'a Utf8Path,
79 version: &'a Version,
80 dep_format: DepFormatVersion,
81 line_style: WorkspaceHackLineStyle,
82 add_to: PackageSet<'g>,
83 },
84 RemoveDependency {
85 name: &'a str,
86 remove_from: PackageSet<'g>,
87 },
88}
89
90impl<'g> WorkspaceOp<'g, '_> {
91 fn apply(&self, canonical_workspace_root: &Utf8Path) -> Result<(), ApplyError> {
92 match self {
93 WorkspaceOp::NewCrate {
94 crate_path,
95 files,
96 root_files,
97 } => {
98 Self::create_new_crate(canonical_workspace_root, crate_path, files)?;
99 let crate_path = canonical_rel_path(crate_path, canonical_workspace_root)?;
101
102 for (rel_path, contents) in root_files {
103 let abs_path = canonical_workspace_root.join(rel_path.as_ref());
104 let parent = abs_path.parent().expect("abs path should have a parent");
105 std::fs::create_dir_all(parent)
106 .map_err(|err| ApplyError::io("error creating directories", parent, err))?;
107 write_contents(contents, &abs_path)?;
108 }
109
110 Self::add_to_root_toml(canonical_workspace_root, &crate_path)
111 }
112 WorkspaceOp::AddDependency {
113 name,
114 crate_path,
115 version,
116 dep_format,
117 line_style,
118 add_to,
119 } => {
120 let crate_path = canonical_rel_path(crate_path, canonical_workspace_root)?;
121 for package in add_to.packages(DependencyDirection::Reverse) {
122 Self::add_to_cargo_toml(
123 name,
124 version,
125 &crate_path,
126 *dep_format,
127 *line_style,
128 package,
129 )?;
130 }
131 Ok(())
132 }
133 WorkspaceOp::RemoveDependency { name, remove_from } => {
134 for package in remove_from.packages(DependencyDirection::Reverse) {
135 Self::remove_from_cargo_toml(name, package)?;
136 }
137 Ok(())
138 }
139 }
140 }
141
142 fn create_new_crate(
147 workspace_root: &Utf8Path,
148 crate_path: &Utf8Path,
149 files: &BTreeMap<Cow<'_, Utf8Path>, Cow<'_, [u8]>>,
150 ) -> Result<(), ApplyError> {
151 let abs_path = workspace_root.join(crate_path);
152 for (path, contents) in files {
153 let mut dir_path = match path.parent() {
155 Some(parent) => abs_path.join(parent),
156 None => abs_path.clone(),
157 };
158 std::fs::create_dir_all(&dir_path)
159 .map_err(|err| ApplyError::io("error creating directories", &dir_path, err))?;
160
161 dir_path.push(
163 path.file_name().ok_or_else(|| {
164 ApplyError::misc("does not contain a file name", path.as_ref())
165 })?,
166 );
167 write_contents(contents, &dir_path)?;
168 }
169 Ok(())
170 }
171
172 fn add_to_root_toml(
173 workspace_root: &Utf8Path,
174 crate_path: &Utf8Path,
175 ) -> Result<(), ApplyError> {
176 let root_toml_path = workspace_root.join("Cargo.toml");
177
178 let mut doc = read_toml(&root_toml_path)?;
179 let members = Self::get_workspace_members_array(&root_toml_path, &mut doc)?;
180
181 let add = |members: &mut Array, idx: usize| {
182 let existing = if idx < members.len() {
184 members.get(idx).expect("valid idx")
185 } else {
186 members.get(members.len() - 1).expect("valid idx")
187 };
188
189 let write_path = with_forward_slashes(crate_path).into_string();
190 let write_path = decorate(existing, write_path);
191 members.insert_formatted(idx, write_path);
192 };
193
194 let mut written = false;
195 for idx in 0..members.len() {
196 let member = members.get(idx).expect("valid idx");
197 match member.as_str() {
198 Some(path) => {
199 let path = Utf8Path::new(path);
200 match path.cmp(crate_path) {
203 Ordering::Greater => {
204 add(members, idx);
205 written = true;
206 break;
207 }
208 Ordering::Equal => {
209 written = true;
211 break;
212 }
213 Ordering::Less => {}
214 }
215 }
216 None => {
217 return Err(ApplyError::misc(
218 "workspace.members contains non-strings",
219 root_toml_path,
220 ))
221 }
222 }
223 }
224
225 if !written {
226 add(members, members.len());
227 }
228
229 write_document(&doc, &root_toml_path)
230 }
231
232 fn get_workspace_members_array<'doc>(
233 root_toml_path: &Utf8Path,
234 doc: &'doc mut DocumentMut,
235 ) -> Result<&'doc mut Array, ApplyError> {
236 let doc_table = doc.as_table_mut();
237 let workspace_table = match doc_table.get_mut("workspace") {
238 Some(Item::Table(workspace_table)) => workspace_table,
239 Some(other) => {
240 return Err(ApplyError::misc(
241 format!(
242 "expected [workspace] to be a table, found {}",
243 other.type_name()
244 ),
245 root_toml_path,
246 ))
247 }
248 None => {
249 return Err(ApplyError::misc(
250 "[workspace] section not found",
251 root_toml_path,
252 ))
253 }
254 };
255
256 let members = match workspace_table.get_mut("members") {
257 Some(Item::Value(ref mut members)) => match members.as_array_mut() {
258 Some(members) => members,
259 None => {
260 return Err(ApplyError::misc(
261 "workspace.members is not an array",
262 root_toml_path,
263 ))
264 }
265 },
266 Some(other) => {
267 return Err(ApplyError::misc(
268 format!(
269 "expected workspace.members to be an array, found {}",
270 other.type_name()
271 ),
272 root_toml_path,
273 ))
274 }
275 None => {
276 return Err(ApplyError::misc(
277 "workspace.members not found",
278 root_toml_path,
279 ))
280 }
281 };
282 Ok(members)
283 }
284
285 fn add_to_cargo_toml(
286 name: &str,
287 version: &Version,
288 crate_path: &Utf8Path,
289 dep_format: DepFormatVersion,
290 line_style: WorkspaceHackLineStyle,
291 package: PackageMetadata<'g>,
292 ) -> Result<(), ApplyError> {
293 let manifest_path = package.manifest_path();
294 let mut doc = read_toml(manifest_path)?;
295 let dep_table = Self::get_or_insert_dependencies_table(manifest_path, &mut doc)?;
296
297 let package_path = package
298 .source()
299 .workspace_path()
300 .expect("package should be in workspace");
301 let path = pathdiff::diff_utf8_paths(crate_path, package_path)
303 .expect("both new_path and package_path are relative");
304
305 let path_table = Self::inline_table_for_add(version, dep_format, line_style, &path);
306
307 dep_table.insert(name, Item::Value(Value::InlineTable(path_table)));
308
309 write_document(&doc, manifest_path)
310 }
311
312 fn inline_table_for_add(
313 version: &Version,
314 dep_format: DepFormatVersion,
315 line_style: WorkspaceHackLineStyle,
316 path: &Utf8Path,
317 ) -> InlineTable {
318 let mut itable = InlineTable::new();
319
320 match line_style {
321 WorkspaceHackLineStyle::Full => {
322 let version_str = format!(
325 "{}",
326 VersionDisplay::new(version, false, dep_format < DepFormatVersion::V3)
327 );
328 if dep_format >= DepFormatVersion::V2 {
329 itable.insert("version", version_str.into());
330 }
331
332 let mut path = Formatted::new(with_forward_slashes(path).into_string());
333 if dep_format == DepFormatVersion::V1 {
334 path.decor_mut().set_suffix("");
338 }
339 itable.insert("path", Value::String(path));
340
341 if dep_format == DepFormatVersion::V2 {
342 itable.fmt();
343 }
344 itable
345 }
346 WorkspaceHackLineStyle::VersionOnly => {
347 let version_str = format!("{}", VersionDisplay::new(version, false, false));
350 itable.insert("version", version_str.into());
351 itable
352 }
353 WorkspaceHackLineStyle::WorkspaceDotted => {
354 itable.insert("workspace", true.into());
357 itable.set_dotted(true);
358 itable
359 }
360 }
361 }
362
363 fn remove_from_cargo_toml(name: &str, package: PackageMetadata<'g>) -> Result<(), ApplyError> {
364 let manifest_path = package.manifest_path();
365 let mut doc = read_toml(manifest_path)?;
366 let dep_table = Self::get_or_insert_dependencies_table(manifest_path, &mut doc)?;
367 dep_table.remove(name);
370
371 write_document(&doc, manifest_path)
372 }
373
374 fn get_or_insert_dependencies_table<'doc>(
375 manifest_path: &Utf8Path,
376 doc: &'doc mut DocumentMut,
377 ) -> Result<&'doc mut dyn TableLike, ApplyError> {
378 let doc_table = doc.as_table_mut();
379
380 if doc_table.contains_key("dependencies") {
381 match doc_table
382 .get_mut("dependencies")
383 .expect("just checked for presence of dependencies")
384 .as_table_like_mut()
385 {
386 Some(table) => Ok(table),
387 None => Err(ApplyError::misc(
388 "[dependencies] is not a table",
389 manifest_path,
390 )),
391 }
392 } else {
393 let mut new_table = Table::new();
395 new_table.set_implicit(true);
396 doc_table.insert("dependencies", Item::Table(new_table));
397 let table = doc_table
398 .get_mut("dependencies")
399 .expect("was just inserted")
400 .as_table_like_mut()
401 .expect("was just inserted");
402 Ok(table)
403 }
404 }
405}
406
407fn decorate(existing: &Value, new: impl Into<Value>) -> Value {
408 let decor = existing.decor();
409 new.into().decorated(
410 decor.prefix().cloned().unwrap_or_default(),
411 decor.suffix().cloned().unwrap_or_default(),
412 )
413}
414
415fn with_forward_slashes(path: &Utf8Path) -> Utf8PathBuf {
417 let components: Vec<_> = path.iter().collect();
418 components.join("/").into()
419}
420
421fn canonical_rel_path(
426 path: &Utf8Path,
427 canonical_base: &Utf8Path,
428) -> Result<Utf8PathBuf, ApplyError> {
429 let abs_path = canonical_base.join(path);
430 let canonical_path = abs_path
432 .canonicalize_utf8()
433 .map_err(|err| ApplyError::io("error canonicalizing path", &abs_path, err))?;
434 canonical_path
435 .strip_prefix(canonical_base)
436 .map_err(|_| {
437 ApplyError::misc(
439 format!("canonical path is not within base path {}", canonical_base),
440 &abs_path,
441 )
442 })
443 .map(|p| p.to_owned())
444}
445
446fn read_toml(manifest_path: &Utf8Path) -> Result<DocumentMut, ApplyError> {
451 let toml = fs::read_to_string(manifest_path)
452 .map_err(|err| ApplyError::io("error reading TOML file", manifest_path, err))?;
453 toml.parse::<DocumentMut>()
454 .map_err(|err| ApplyError::toml("error deserializing TOML file", manifest_path, err))
455}
456
457fn write_contents(contents: &[u8], path: &Utf8Path) -> Result<(), ApplyError> {
458 write_atomic(path, |file| file.write_all(contents))
459}
460
461fn write_document(document: &DocumentMut, path: &Utf8Path) -> Result<(), ApplyError> {
462 write_atomic(path, |file| write!(file, "{}", document))
463}
464
465fn write_atomic(
466 path: &Utf8Path,
467 cb: impl FnOnce(&mut fs::File) -> Result<(), io::Error>,
468) -> Result<(), ApplyError> {
469 let atomic_file = AtomicFile::new(path, OverwriteBehavior::AllowOverwrite);
470 match atomic_file.write(cb) {
471 Ok(()) => Ok(()),
472 Err(atomicwrites::Error::Internal(err)) | Err(atomicwrites::Error::User(err)) => {
473 Err(ApplyError::io("error writing file", path, err))
474 }
475 }
476}
477
478#[derive(Debug)]
480pub struct ApplyError {
481 message: String,
482 path: Utf8PathBuf,
483 kind: Box<ApplyErrorKind>,
484}
485
486impl ApplyError {
487 #[inline]
489 pub fn message(&self) -> &str {
490 &self.message
491 }
492
493 #[inline]
495 pub fn path(&self) -> &Utf8Path {
496 &self.path
497 }
498
499 fn io(message: impl Into<String>, path: impl Into<Utf8PathBuf>, err: io::Error) -> Self {
503 Self {
504 message: message.into(),
505 path: path.into(),
506 kind: Box::new(ApplyErrorKind::Io { err }),
507 }
508 }
509
510 fn toml(
511 message: impl Into<String>,
512 path: impl Into<Utf8PathBuf>,
513 err: toml_edit::TomlError,
514 ) -> Self {
515 Self {
516 message: message.into(),
517 path: path.into(),
518 kind: Box::new(ApplyErrorKind::Toml { err }),
519 }
520 }
521
522 fn misc(message: impl Into<String>, path: impl Into<Utf8PathBuf>) -> Self {
523 Self {
524 message: message.into(),
525 path: path.into(),
526 kind: Box::new(ApplyErrorKind::Misc),
527 }
528 }
529}
530
531impl fmt::Display for ApplyError {
532 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
533 write!(f, "for path {}, {}", self.path, self.message)
534 }
535}
536
537impl error::Error for ApplyError {
538 fn source(&self) -> Option<&(dyn error::Error + 'static)> {
539 match &*self.kind {
540 ApplyErrorKind::Io { err } => Some(err),
541 ApplyErrorKind::Toml { err } => Some(err),
542 ApplyErrorKind::Misc => None,
543 }
544 }
545}
546
547#[derive(Debug)]
548enum ApplyErrorKind {
549 Io { err: io::Error },
550 Toml { err: TomlError },
551 Misc,
552}
553
554#[derive(Clone, Debug)]
556pub struct WorkspaceOpsDisplay<'g, 'a, 'ops> {
557 ops: &'ops WorkspaceOps<'g, 'a>,
558 styles: Box<Styles>,
559}
560
561impl<'g, 'a, 'ops> WorkspaceOpsDisplay<'g, 'a, 'ops> {
562 fn new(ops: &'ops WorkspaceOps<'g, 'a>) -> Self {
563 Self {
564 ops,
565 styles: Box::default(),
566 }
567 }
568
569 pub fn colorize(&mut self) -> &mut Self {
571 self.styles.colorize();
572 self
573 }
574}
575
576impl fmt::Display for WorkspaceOpsDisplay<'_, '_, '_> {
577 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578 let workspace_root = self.ops.graph.workspace().root();
579 let workspace_root_manifest = workspace_root.join("Cargo.toml");
580 for op in &self.ops.ops {
581 match op {
582 WorkspaceOp::NewCrate {
583 crate_path,
584 files,
585 root_files,
586 } => {
587 write!(
588 f,
589 "* {} at {}",
590 "create crate".style(self.styles.create_bold_style),
591 crate_path.style(self.styles.create_bold_style),
592 )?;
593 if !files.is_empty() {
594 writeln!(f, ", with files:")?;
595 for file in files.keys() {
596 writeln!(f, " - {}", file.style(self.styles.create_style))?;
597 }
598 } else {
599 writeln!(f)?;
600 }
601 writeln!(
602 f,
603 "* {} at {} to {}",
604 "add crate".style(self.styles.add_bold_style),
605 crate_path.style(self.styles.add_style),
606 workspace_root_manifest.style(self.styles.add_to_style),
607 )?;
608 if !root_files.is_empty() {
609 writeln!(
610 f,
611 "* {} at workspace root:",
612 "create files".style(self.styles.create_bold_style)
613 )?;
614 for file in root_files.keys() {
615 writeln!(f, " - {}", file.style(self.styles.create_style))?;
616 }
617 }
618 }
619 WorkspaceOp::AddDependency {
620 name,
621 version,
622 crate_path,
623 dep_format: _,
624 line_style: _,
625 add_to,
626 } => {
627 writeln!(
628 f,
629 "* {} {} v{} (at path {}) to packages:",
630 "add or update dependency".style(self.styles.add_bold_style),
631 name.style(self.styles.add_style),
632 version.style(self.styles.add_style),
633 crate_path.style(self.styles.add_style),
634 )?;
635 for (name, path) in package_names_paths(add_to) {
636 writeln!(
637 f,
638 " - {} (at path {})",
639 name.style(self.styles.add_to_bold_style),
640 path.style(self.styles.add_to_style)
641 )?;
642 }
643 }
644 WorkspaceOp::RemoveDependency { name, remove_from } => {
645 writeln!(
646 f,
647 "* {} {} from packages:",
648 "remove dependency".style(self.styles.remove_bold_style),
649 name.style(self.styles.remove_style),
650 )?;
651 for (name, path) in package_names_paths(remove_from) {
652 writeln!(
653 f,
654 " - {} (at path {})",
655 name.style(self.styles.remove_from_bold_style),
656 path.style(self.styles.remove_from_style)
657 )?;
658 }
659 }
660 }
661 }
662 Ok(())
663 }
664}
665
666#[derive(Clone, Debug, Default)]
667struct Styles {
668 create_style: Style,
669 add_style: Style,
670 add_to_style: Style,
671 remove_style: Style,
672 remove_from_style: Style,
673 create_bold_style: Style,
674 add_bold_style: Style,
675 add_to_bold_style: Style,
676 remove_bold_style: Style,
677 remove_from_bold_style: Style,
678}
679
680impl Styles {
681 fn colorize(&mut self) {
682 self.create_style = Style::new().green();
683 self.add_style = Style::new().cyan();
684 self.add_to_style = Style::new().blue();
685 self.remove_style = Style::new().red();
686 self.remove_from_style = Style::new().purple();
687 self.create_bold_style = self.create_style.bold();
688 self.add_bold_style = self.add_style.bold();
689 self.add_to_bold_style = self.add_to_style.bold();
690 self.remove_bold_style = self.remove_style.bold();
691 self.remove_from_bold_style = self.remove_from_style.bold();
692 }
693}
694
695fn package_names_paths<'g>(package_set: &PackageSet<'g>) -> Vec<(&'g str, &'g Utf8Path)> {
696 let mut package_names_paths: Vec<_> = package_set
697 .packages(DependencyDirection::Forward)
698 .map(|package| {
699 (
700 package.name(),
701 package
702 .source()
703 .workspace_path()
704 .expect("workspace package"),
705 )
706 })
707 .collect();
708 package_names_paths.sort_unstable();
709 package_names_paths
710}
711
712#[cfg(test)]
713mod tests {
714 use super::*;
715
716 #[test]
717 fn test_inline_table_for_add() {
718 let versions = vec![
719 ("1.2.3", "1", "1"),
720 ("1.2.3-a.1+g456", "1.2.3-a.1+g456", "1.2.3-a.1"),
721 ];
722
723 for (version, version_str, version_str_v3) in versions {
724 let version: Version = version.parse().unwrap();
725 let itable = WorkspaceOp::inline_table_for_add(
726 &version,
727 DepFormatVersion::V1,
728 WorkspaceHackLineStyle::Full,
729 "../../path".into(),
730 );
731 assert_eq!(
732 itable.to_string(),
733 "{ path = \"../../path\"}",
734 "dep format v1 matches"
735 );
736
737 let itable = WorkspaceOp::inline_table_for_add(
738 &version,
739 DepFormatVersion::V2,
740 WorkspaceHackLineStyle::Full,
741 "../../path".into(),
742 );
743 assert_eq!(
744 itable.to_string(),
745 format!("{{ version = \"{}\", path = \"../../path\" }}", version_str),
746 "dep format v2 matches"
747 );
748
749 let itable = WorkspaceOp::inline_table_for_add(
750 &version,
751 DepFormatVersion::V3,
752 WorkspaceHackLineStyle::Full,
753 "../../path".into(),
754 );
755 assert_eq!(
756 itable.to_string(),
757 format!(
758 "{{ version = \"{}\", path = \"../../path\" }}",
759 version_str_v3
760 ),
761 "dep format v3 matches"
762 );
763
764 let itable = WorkspaceOp::inline_table_for_add(
765 &version,
766 DepFormatVersion::V4,
767 WorkspaceHackLineStyle::VersionOnly,
768 "../../path".into(),
769 );
770 assert_eq!(
771 itable.to_string(),
772 format!("{{ version = \"{}\" }}", version_str_v3),
773 "version only matches"
774 );
775
776 let itable = WorkspaceOp::inline_table_for_add(
777 &version,
778 DepFormatVersion::V4,
779 WorkspaceHackLineStyle::WorkspaceDotted,
780 "../../path".into(),
781 );
782 let mut document = DocumentMut::new();
783 document
784 .as_table_mut()
785 .insert("workspace-hack", Item::Value(Value::InlineTable(itable)));
786 assert_eq!(
787 document.to_string(),
788 "workspace-hack.workspace = true\n",
789 "workspace dep matches"
790 );
791 }
792 }
793}