1use crate::{
5 hakari::{DepFormatVersion, WorkspaceHackLineStyle},
6 helpers::VersionDisplay,
7};
8use atomicwrites::{AtomicFile, OverwriteBehavior};
9use camino::{Utf8Path, Utf8PathBuf};
10use guppy::{
11 Version,
12 graph::{DependencyDirection, PackageGraph, PackageMetadata, PackageSet},
13};
14use owo_colors::{OwoColorize, Style};
15use std::{borrow::Cow, cmp::Ordering, collections::BTreeMap, error, fmt, fs, io, io::Write};
16use thiserror::Error;
17use toml_edit::{
18 Array, DocumentMut, Formatted, InlineTable, Item, Table, TableLike, TomlError, Value,
19};
20
21#[derive(Clone, Debug)]
23pub struct WorkspaceOps<'g, 'a> {
24 graph: &'g PackageGraph,
25 ops: Vec<WorkspaceOp<'g, 'a>>,
26}
27
28impl<'g, 'a> WorkspaceOps<'g, 'a> {
29 pub(crate) fn new(
30 graph: &'g PackageGraph,
31 ops: impl IntoIterator<Item = WorkspaceOp<'g, 'a>>,
32 ) -> Self {
33 Self {
34 graph,
35 ops: ops.into_iter().collect(),
36 }
37 }
38
39 #[cfg(test)]
40 pub(crate) fn ops(&self) -> &[WorkspaceOp<'g, 'a>] {
41 &self.ops
42 }
43
44 #[inline]
46 pub fn display<'ops>(&'ops self) -> WorkspaceOpsDisplay<'g, 'a, 'ops> {
47 WorkspaceOpsDisplay::new(self)
48 }
49
50 #[inline]
52 pub fn is_empty(&self) -> bool {
53 self.ops.is_empty()
54 }
55
56 pub fn apply(&self) -> Result<(), ApplyError> {
60 let workspace_root = self.graph.workspace().root();
61 let canonical_workspace_root = workspace_root.canonicalize_utf8().map_err(|error| {
62 ApplyError::new(
63 workspace_root,
64 ApplyErrorKind::CanonicalizeWorkspaceRoot { error },
65 )
66 })?;
67 for op in &self.ops {
68 op.apply(&canonical_workspace_root)?;
69 }
70 Ok(())
71 }
72}
73
74#[derive(Clone, Debug)]
75pub(crate) enum WorkspaceOp<'g, 'a> {
76 NewCrate {
77 crate_path: &'a Utf8Path,
78 files: BTreeMap<Cow<'a, Utf8Path>, Cow<'a, [u8]>>,
79 root_files: BTreeMap<Cow<'a, Utf8Path>, Cow<'a, [u8]>>,
80 },
81 AddDependency {
82 name: &'a str,
83 crate_path: &'a Utf8Path,
84 version: &'a Version,
85 dep_format: DepFormatVersion,
86 line_style: WorkspaceHackLineStyle,
87 add_to: PackageSet<'g>,
88 },
89 RemoveDependency {
90 name: &'a str,
91 remove_from: PackageSet<'g>,
92 },
93}
94
95impl<'g> WorkspaceOp<'g, '_> {
96 fn apply(&self, canonical_workspace_root: &Utf8Path) -> Result<(), ApplyError> {
97 match self {
98 WorkspaceOp::NewCrate {
99 crate_path,
100 files,
101 root_files,
102 } => {
103 Self::create_new_crate(canonical_workspace_root, crate_path, files)?;
104 let crate_path = canonical_rel_path(crate_path, canonical_workspace_root)?;
106
107 for (rel_path, contents) in root_files {
108 let abs_path = canonical_workspace_root.join(rel_path.as_ref());
109 let parent = abs_path.parent().expect("abs path should have a parent");
110 std::fs::create_dir_all(parent).map_err(|error| {
111 ApplyError::new(parent, ApplyErrorKind::CreateDirs { error })
112 })?;
113 write_contents(contents, &abs_path)?;
114 }
115
116 Self::add_to_root_toml(canonical_workspace_root, &crate_path)
117 }
118 WorkspaceOp::AddDependency {
119 name,
120 crate_path,
121 version,
122 dep_format,
123 line_style,
124 add_to,
125 } => {
126 let crate_path = canonical_rel_path(crate_path, canonical_workspace_root)?;
127 for package in add_to.packages(DependencyDirection::Reverse) {
128 Self::add_to_cargo_toml(
129 name,
130 version,
131 &crate_path,
132 *dep_format,
133 *line_style,
134 package,
135 )?;
136 }
137 Ok(())
138 }
139 WorkspaceOp::RemoveDependency { name, remove_from } => {
140 for package in remove_from.packages(DependencyDirection::Reverse) {
141 Self::remove_from_cargo_toml(name, package)?;
142 }
143 Ok(())
144 }
145 }
146 }
147
148 fn create_new_crate(
153 workspace_root: &Utf8Path,
154 crate_path: &Utf8Path,
155 files: &BTreeMap<Cow<'_, Utf8Path>, Cow<'_, [u8]>>,
156 ) -> Result<(), ApplyError> {
157 let abs_path = workspace_root.join(crate_path);
158 for (path, contents) in files {
159 let mut dir_path = match path.parent() {
161 Some(parent) => abs_path.join(parent),
162 None => abs_path.clone(),
163 };
164 std::fs::create_dir_all(&dir_path).map_err(|error| {
165 ApplyError::new(&dir_path, ApplyErrorKind::CreateDirs { error })
166 })?;
167
168 dir_path.push(
170 path.file_name().ok_or_else(|| {
171 ApplyError::new(path.as_ref(), ApplyErrorKind::MissingFileName)
172 })?,
173 );
174 write_contents(contents, &dir_path)?;
175 }
176 Ok(())
177 }
178
179 fn add_to_root_toml(
180 workspace_root: &Utf8Path,
181 crate_path: &Utf8Path,
182 ) -> Result<(), ApplyError> {
183 let root_toml_path = workspace_root.join("Cargo.toml");
184
185 let mut doc = read_toml(&root_toml_path)?;
186 Self::add_to_workspace_members(&mut doc, crate_path).map_err(|error| {
187 ApplyError::new(&root_toml_path, ApplyErrorKind::WorkspaceSection { error })
188 })?;
189 write_document(&doc, &root_toml_path)
190 }
191
192 fn add_to_workspace_members(
193 doc: &mut DocumentMut,
194 crate_path: &Utf8Path,
195 ) -> Result<(), WorkspaceSectionError> {
196 let members = Self::get_workspace_members_array(doc)?;
197
198 let add = |members: &mut Array, idx: usize| {
199 let existing = if idx < members.len() {
201 members.get(idx).expect("valid idx")
202 } else {
203 members.get(members.len() - 1).expect("valid idx")
204 };
205
206 let write_path = with_forward_slashes(crate_path).into_string();
207 let write_path = decorate(existing, write_path);
208 members.insert_formatted(idx, write_path);
209 };
210
211 let mut written = false;
212 for idx in 0..members.len() {
213 let member = members.get(idx).expect("valid idx");
214 match member.as_str() {
215 Some(path) => {
216 let path = Utf8Path::new(path);
217 match path.cmp(crate_path) {
220 Ordering::Greater => {
221 add(members, idx);
222 written = true;
223 break;
224 }
225 Ordering::Equal => {
226 written = true;
228 break;
229 }
230 Ordering::Less => {}
231 }
232 }
233 None => {
234 return Err(WorkspaceSectionError::MemberNotAString {
235 index: idx,
236 found: member.type_name(),
237 });
238 }
239 }
240 }
241
242 if !written {
243 add(members, members.len());
244 }
245
246 Ok(())
247 }
248
249 fn get_workspace_members_array(
250 doc: &mut DocumentMut,
251 ) -> Result<&mut Array, WorkspaceSectionError> {
252 let doc_table = doc.as_table_mut();
253 let workspace_table = match doc_table.get_mut("workspace") {
254 Some(Item::Table(workspace_table)) => workspace_table,
255 Some(other) => {
256 return Err(WorkspaceSectionError::NotATable {
257 found: other.type_name(),
258 });
259 }
260 None => {
261 return Err(WorkspaceSectionError::NotFound);
262 }
263 };
264
265 let members = match workspace_table.get_mut("members") {
266 Some(Item::Value(Value::Array(members))) => members,
267 Some(other) => {
268 return Err(WorkspaceSectionError::MembersNotAnArray {
269 found: other.type_name(),
270 });
271 }
272 None => {
273 return Err(WorkspaceSectionError::MembersNotFound);
274 }
275 };
276 Ok(members)
277 }
278
279 fn add_to_cargo_toml(
280 name: &str,
281 version: &Version,
282 crate_path: &Utf8Path,
283 dep_format: DepFormatVersion,
284 line_style: WorkspaceHackLineStyle,
285 package: PackageMetadata<'g>,
286 ) -> Result<(), ApplyError> {
287 let manifest_path = package.manifest_path();
288 let mut doc = read_toml(manifest_path)?;
289
290 let package_path = package
291 .source()
292 .workspace_path()
293 .expect("package should be in workspace");
294 let path = pathdiff::diff_utf8_paths(crate_path, package_path)
296 .expect("both new_path and package_path are relative");
297
298 let path_table = Self::inline_table_for_add(version, dep_format, line_style, &path);
299
300 add_dependency_to_document(&mut doc, name, path_table)
301 .map_err(|error| ApplyError::new(manifest_path, ApplyErrorKind::NotATable { error }))?;
302
303 write_document(&doc, manifest_path)
304 }
305
306 fn inline_table_for_add(
307 version: &Version,
308 dep_format: DepFormatVersion,
309 line_style: WorkspaceHackLineStyle,
310 path: &Utf8Path,
311 ) -> InlineTable {
312 let mut itable = InlineTable::new();
313
314 match line_style {
315 WorkspaceHackLineStyle::Full => {
316 let version_str = format!(
319 "{}",
320 VersionDisplay::new(version, false, dep_format < DepFormatVersion::V3)
321 );
322 if dep_format >= DepFormatVersion::V2 {
323 itable.insert("version", version_str.into());
324 }
325
326 let mut path = Formatted::new(with_forward_slashes(path).into_string());
327 if dep_format == DepFormatVersion::V1 {
328 path.decor_mut().set_suffix("");
332 }
333 itable.insert("path", Value::String(path));
334
335 if dep_format == DepFormatVersion::V2 {
336 itable.fmt();
337 }
338 itable
339 }
340 WorkspaceHackLineStyle::VersionOnly => {
341 let version_str = format!("{}", VersionDisplay::new(version, false, false));
344 itable.insert("version", version_str.into());
345 itable
346 }
347 WorkspaceHackLineStyle::WorkspaceDotted => {
348 itable.insert("workspace", true.into());
351 itable.set_dotted(true);
352 itable
353 }
354 }
355 }
356
357 fn remove_from_cargo_toml(name: &str, package: PackageMetadata<'g>) -> Result<(), ApplyError> {
358 let manifest_path = package.manifest_path();
359 let mut doc = read_toml(manifest_path)?;
360 remove_dependency_from_document(&mut doc, name)
361 .map_err(|error| ApplyError::new(manifest_path, ApplyErrorKind::NotATable { error }))?;
362
363 write_document(&doc, manifest_path)
364 }
365}
366
367#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373pub enum DependencySection {
374 Normal,
377 Dev,
380 Build,
383}
384
385impl DependencySection {
386 const ALL: [Self; 3] = [Self::Normal, Self::Dev, Self::Build];
387
388 fn key(self) -> &'static str {
389 match self {
390 DependencySection::Normal => "dependencies",
391 DependencySection::Dev => "dev-dependencies",
392 DependencySection::Build => "build-dependencies",
393 }
394 }
395}
396
397#[derive(Clone, Debug, Eq, PartialEq, Error)]
400#[non_exhaustive]
401pub enum NotATableError {
402 #[error("[{}] is not a table", .section.key())]
404 Section {
405 section: DependencySection,
407 },
408 #[error("[target] is not a table")]
410 Target,
411 #[error("[target.'{platform}'] is not a table")]
413 Platform {
414 platform: String,
416 },
417 #[error("[target.'{platform}'.{}] is not a table", .section.key())]
419 PlatformSection {
420 platform: String,
422 section: DependencySection,
424 },
425}
426
427fn add_dependency_to_document(
436 doc: &mut DocumentMut,
437 name: &str,
438 dep: InlineTable,
439) -> Result<(), NotATableError> {
440 let dep_table = get_or_insert_dependency_section(doc, DependencySection::Normal)?;
441 dep_table.insert(name, Item::Value(Value::InlineTable(dep)));
442 if let Some(dev_table) = get_dependency_section(doc, DependencySection::Dev)? {
443 dev_table.remove(name);
444 }
445 Ok(())
446}
447
448fn remove_dependency_from_document(
451 doc: &mut DocumentMut,
452 name: &str,
453) -> Result<(), NotATableError> {
454 for section in DependencySection::ALL {
457 if let Some(dep_table) = get_dependency_section(doc, section)? {
458 dep_table.remove(name);
459 }
460 }
461
462 let Some(target_item) = doc.as_table_mut().get_mut("target") else {
463 return Ok(());
464 };
465 let Some(target_table) = target_item.as_table_like_mut() else {
466 return Err(NotATableError::Target);
467 };
468 for (platform, platform_item) in target_table.iter_mut() {
469 let platform = platform.get();
470 let Some(platform_table) = platform_item.as_table_like_mut() else {
471 return Err(NotATableError::Platform {
472 platform: platform.to_owned(),
473 });
474 };
475 for section in DependencySection::ALL {
476 let Some(section_item) = platform_table.get_mut(section.key()) else {
477 continue;
478 };
479 let Some(dep_table) = section_item.as_table_like_mut() else {
480 return Err(NotATableError::PlatformSection {
481 platform: platform.to_owned(),
482 section,
483 });
484 };
485 dep_table.remove(name);
486 }
487 }
488 Ok(())
489}
490
491fn get_dependency_section(
493 doc: &mut DocumentMut,
494 section: DependencySection,
495) -> Result<Option<&mut dyn TableLike>, NotATableError> {
496 let key = section.key();
497 match doc.as_table_mut().get_mut(key) {
498 Some(item) => match item.as_table_like_mut() {
499 Some(table) => Ok(Some(table)),
500 None => Err(NotATableError::Section { section }),
501 },
502 None => Ok(None),
503 }
504}
505
506fn get_or_insert_dependency_section(
507 doc: &mut DocumentMut,
508 section: DependencySection,
509) -> Result<&mut dyn TableLike, NotATableError> {
510 let key = section.key();
511 let doc_table = doc.as_table_mut();
512
513 if doc_table.contains_key(key) {
514 match doc_table
515 .get_mut(key)
516 .expect("just checked for presence of section")
517 .as_table_like_mut()
518 {
519 Some(table) => Ok(table),
520 None => Err(NotATableError::Section { section }),
521 }
522 } else {
523 let mut new_table = Table::new();
525 new_table.set_implicit(true);
526 doc_table.insert(key, Item::Table(new_table));
527 let table = doc_table
528 .get_mut(key)
529 .expect("was just inserted")
530 .as_table_like_mut()
531 .expect("was just inserted");
532 Ok(table)
533 }
534}
535
536fn decorate(existing: &Value, new: impl Into<Value>) -> Value {
537 let decor = existing.decor();
538 new.into().decorated(
539 decor.prefix().cloned().unwrap_or_default(),
540 decor.suffix().cloned().unwrap_or_default(),
541 )
542}
543
544fn with_forward_slashes(path: &Utf8Path) -> Utf8PathBuf {
546 let components: Vec<_> = path.iter().collect();
547 components.join("/").into()
548}
549
550fn canonical_rel_path(
555 path: &Utf8Path,
556 canonical_base: &Utf8Path,
557) -> Result<Utf8PathBuf, ApplyError> {
558 let abs_path = canonical_base.join(path);
559 let canonical_path = abs_path
561 .canonicalize_utf8()
562 .map_err(|error| ApplyError::new(&abs_path, ApplyErrorKind::CanonicalizePath { error }))?;
563 match canonical_path.strip_prefix(canonical_base) {
564 Ok(rel_path) => Ok(rel_path.to_owned()),
565 Err(_) => {
566 Err(ApplyError::new(
568 &abs_path,
569 ApplyErrorKind::PathOutsideBase {
570 canonical_path,
571 canonical_base: canonical_base.to_owned(),
572 },
573 ))
574 }
575 }
576}
577
578fn read_toml(manifest_path: &Utf8Path) -> Result<DocumentMut, ApplyError> {
583 let toml = fs::read_to_string(manifest_path)
584 .map_err(|error| ApplyError::new(manifest_path, ApplyErrorKind::ReadManifest { error }))?;
585 parse_toml(&toml, manifest_path)
586}
587
588fn parse_toml(toml: &str, manifest_path: &Utf8Path) -> Result<DocumentMut, ApplyError> {
589 toml.parse::<DocumentMut>()
590 .map_err(|error| ApplyError::new(manifest_path, ApplyErrorKind::ParseManifest { error }))
591}
592
593fn write_contents(contents: &[u8], path: &Utf8Path) -> Result<(), ApplyError> {
594 write_atomic(path, |file| file.write_all(contents))
595}
596
597fn write_document(document: &DocumentMut, path: &Utf8Path) -> Result<(), ApplyError> {
598 write_atomic(path, |file| write!(file, "{document}"))
599}
600
601fn write_atomic(
602 path: &Utf8Path,
603 cb: impl FnOnce(&mut fs::File) -> Result<(), io::Error>,
604) -> Result<(), ApplyError> {
605 let atomic_file = AtomicFile::new(path, OverwriteBehavior::AllowOverwrite);
606 match atomic_file.write(cb) {
607 Ok(()) => Ok(()),
608 Err(atomicwrites::Error::Internal(error)) | Err(atomicwrites::Error::User(error)) => {
609 Err(ApplyError::new(path, ApplyErrorKind::WriteFile { error }))
610 }
611 }
612}
613
614#[derive(Debug)]
616pub struct ApplyError {
617 path: Utf8PathBuf,
618 kind: Box<ApplyErrorKind>,
619}
620
621impl ApplyError {
622 #[inline]
624 pub fn path(&self) -> &Utf8Path {
625 &self.path
626 }
627
628 #[inline]
630 pub fn kind(&self) -> &ApplyErrorKind {
631 &self.kind
632 }
633
634 fn new(path: impl Into<Utf8PathBuf>, kind: ApplyErrorKind) -> Self {
635 Self {
636 path: path.into(),
637 kind: Box::new(kind),
638 }
639 }
640}
641
642impl fmt::Display for ApplyError {
643 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
644 write!(f, "for path {}, {}", self.path, self.kind)
645 }
646}
647
648impl error::Error for ApplyError {
649 fn source(&self) -> Option<&(dyn error::Error + 'static)> {
650 error::Error::source(&*self.kind)
653 }
654}
655
656#[derive(Debug, Error)]
661#[non_exhaustive]
662pub enum ApplyErrorKind {
663 #[error("unable to canonicalize workspace root")]
665 CanonicalizeWorkspaceRoot {
666 #[source]
668 error: io::Error,
669 },
670
671 #[error("error canonicalizing path")]
673 CanonicalizePath {
674 #[source]
676 error: io::Error,
677 },
678
679 #[error("canonical path is not within base path {canonical_base}")]
683 PathOutsideBase {
684 canonical_path: Utf8PathBuf,
686 canonical_base: Utf8PathBuf,
688 },
689
690 #[error("error creating directories")]
692 CreateDirs {
693 #[source]
695 error: io::Error,
696 },
697
698 #[error("does not contain a file name")]
702 MissingFileName,
703
704 #[error("error reading TOML file")]
706 ReadManifest {
707 #[source]
709 error: io::Error,
710 },
711
712 #[error("error deserializing TOML file")]
714 ParseManifest {
715 #[source]
717 error: TomlError,
718 },
719
720 #[error("error writing file")]
722 WriteFile {
723 #[source]
725 error: io::Error,
726 },
727
728 #[error(transparent)]
731 WorkspaceSection {
732 error: WorkspaceSectionError,
734 },
735
736 #[error(transparent)]
738 NotATable {
739 error: NotATableError,
741 },
742}
743
744#[derive(Clone, Debug, Eq, PartialEq, Error)]
748#[non_exhaustive]
749pub enum WorkspaceSectionError {
750 #[error("[workspace] section not found")]
752 NotFound,
753
754 #[error("expected [workspace] to be a table, found {found}")]
758 NotATable {
759 found: &'static str,
762 },
763
764 #[error("workspace.members not found")]
766 MembersNotFound,
767
768 #[error("expected workspace.members to be an array, found {found}")]
770 MembersNotAnArray {
771 found: &'static str,
774 },
775
776 #[error("workspace.members contains non-strings")]
778 MemberNotAString {
779 index: usize,
781 found: &'static str,
784 },
785}
786
787#[derive(Clone, Debug)]
789pub struct WorkspaceOpsDisplay<'g, 'a, 'ops> {
790 ops: &'ops WorkspaceOps<'g, 'a>,
791 styles: Box<Styles>,
792}
793
794impl<'g, 'a, 'ops> WorkspaceOpsDisplay<'g, 'a, 'ops> {
795 fn new(ops: &'ops WorkspaceOps<'g, 'a>) -> Self {
796 Self {
797 ops,
798 styles: Box::default(),
799 }
800 }
801
802 pub fn colorize(&mut self) -> &mut Self {
804 self.styles.colorize();
805 self
806 }
807}
808
809impl fmt::Display for WorkspaceOpsDisplay<'_, '_, '_> {
810 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
811 let workspace_root = self.ops.graph.workspace().root();
812 let workspace_root_manifest = workspace_root.join("Cargo.toml");
813 for op in &self.ops.ops {
814 match op {
815 WorkspaceOp::NewCrate {
816 crate_path,
817 files,
818 root_files,
819 } => {
820 write!(
821 f,
822 "* {} at {}",
823 "create crate".style(self.styles.create_bold_style),
824 crate_path.style(self.styles.create_bold_style),
825 )?;
826 if !files.is_empty() {
827 writeln!(f, ", with files:")?;
828 for file in files.keys() {
829 writeln!(f, " - {}", file.style(self.styles.create_style))?;
830 }
831 } else {
832 writeln!(f)?;
833 }
834 writeln!(
835 f,
836 "* {} at {} to {}",
837 "add crate".style(self.styles.add_bold_style),
838 crate_path.style(self.styles.add_style),
839 workspace_root_manifest.style(self.styles.add_to_style),
840 )?;
841 if !root_files.is_empty() {
842 writeln!(
843 f,
844 "* {} at workspace root:",
845 "create files".style(self.styles.create_bold_style)
846 )?;
847 for file in root_files.keys() {
848 writeln!(f, " - {}", file.style(self.styles.create_style))?;
849 }
850 }
851 }
852 WorkspaceOp::AddDependency {
853 name,
854 version,
855 crate_path,
856 dep_format: _,
857 line_style: _,
858 add_to,
859 } => {
860 writeln!(
861 f,
862 "* {} {} v{} (at path {}) to packages:",
863 "add or update dependency".style(self.styles.add_bold_style),
864 name.style(self.styles.add_style),
865 version.style(self.styles.add_style),
866 crate_path.style(self.styles.add_style),
867 )?;
868 for (name, path) in package_names_paths(add_to) {
869 writeln!(
870 f,
871 " - {} (at path {})",
872 name.style(self.styles.add_to_bold_style),
873 path.style(self.styles.add_to_style)
874 )?;
875 }
876 }
877 WorkspaceOp::RemoveDependency { name, remove_from } => {
878 writeln!(
879 f,
880 "* {} {} from packages:",
881 "remove dependency".style(self.styles.remove_bold_style),
882 name.style(self.styles.remove_style),
883 )?;
884 for (name, path) in package_names_paths(remove_from) {
885 writeln!(
886 f,
887 " - {} (at path {})",
888 name.style(self.styles.remove_from_bold_style),
889 path.style(self.styles.remove_from_style)
890 )?;
891 }
892 }
893 }
894 }
895 Ok(())
896 }
897}
898
899#[derive(Clone, Debug, Default)]
900struct Styles {
901 create_style: Style,
902 add_style: Style,
903 add_to_style: Style,
904 remove_style: Style,
905 remove_from_style: Style,
906 create_bold_style: Style,
907 add_bold_style: Style,
908 add_to_bold_style: Style,
909 remove_bold_style: Style,
910 remove_from_bold_style: Style,
911}
912
913impl Styles {
914 fn colorize(&mut self) {
915 self.create_style = Style::new().green();
916 self.add_style = Style::new().cyan();
917 self.add_to_style = Style::new().blue();
918 self.remove_style = Style::new().red();
919 self.remove_from_style = Style::new().purple();
920 self.create_bold_style = self.create_style.bold();
921 self.add_bold_style = self.add_style.bold();
922 self.add_to_bold_style = self.add_to_style.bold();
923 self.remove_bold_style = self.remove_style.bold();
924 self.remove_from_bold_style = self.remove_from_style.bold();
925 }
926}
927
928fn package_names_paths<'g>(package_set: &PackageSet<'g>) -> Vec<(&'g str, &'g Utf8Path)> {
929 let mut package_names_paths: Vec<_> = package_set
930 .packages(DependencyDirection::Forward)
931 .map(|package| {
932 (
933 package.name(),
934 package
935 .source()
936 .workspace_path()
937 .expect("workspace package"),
938 )
939 })
940 .collect();
941 package_names_paths.sort_unstable();
942 package_names_paths
943}
944
945#[cfg(test)]
946mod tests {
947 use super::*;
948 use fixtures::json::JsonFixture;
949 use std::iter;
950
951 fn hack_dep() -> InlineTable {
952 WorkspaceOp::inline_table_for_add(
953 &"0.1.0".parse().expect("valid version"),
954 DepFormatVersion::V4,
955 WorkspaceHackLineStyle::Full,
956 "../workspace-hack".into(),
957 )
958 }
959
960 fn parse(toml: &str) -> DocumentMut {
961 toml.parse().expect("test manifest is valid TOML")
962 }
963
964 fn canonical_manifest_dir() -> Utf8PathBuf {
965 Utf8Path::new(env!("CARGO_MANIFEST_DIR"))
966 .canonicalize_utf8()
967 .expect("crate directory is canonicalized")
968 }
969
970 fn file_map(path: &str) -> BTreeMap<Cow<'_, Utf8Path>, Cow<'_, [u8]>> {
971 BTreeMap::from([(Cow::Borrowed(Utf8Path::new(path)), Cow::Borrowed(&[][..]))])
972 }
973
974 fn io_error() -> io::Error {
975 io::Error::other("io error")
976 }
977
978 #[test]
979 fn add_dependency_creates_section() {
980 let mut doc = parse(
981 r#"[package]
982name = "foo"
983"#,
984 );
985 add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
986 .expect("[dependencies] is created");
987 assert_eq!(
988 doc.to_string(),
989 r#"[package]
990name = "foo"
991
992[dependencies]
993workspace-hack = { version = "0.1", path = "../workspace-hack" }
994"#,
995 );
996 }
997
998 #[test]
999 fn add_dependency_replaces_existing_line() {
1000 let mut doc = parse(
1001 r#"[dependencies]
1002workspace-hack = { path = "../old" }
1003other = "1"
1004"#,
1005 );
1006 add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
1007 .expect("[dependencies] is a table");
1008 assert_eq!(
1009 doc.to_string(),
1010 r#"[dependencies]
1011workspace-hack = { version = "0.1", path = "../workspace-hack" }
1012other = "1"
1013"#,
1014 "the existing line is replaced in place"
1015 );
1016 }
1017
1018 #[test]
1019 fn add_dependency_drops_dev_dependency_line() {
1020 let mut doc = parse(
1021 r#"[package]
1022name = "foo"
1023
1024[dev-dependencies]
1025workspace-hack = { path = "../workspace-hack" }
1026other = "1"
1027
1028[build-dependencies]
1029workspace-hack = { path = "../workspace-hack" }
1030"#,
1031 );
1032 add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
1033 .expect("[dependencies] is created");
1034 assert_eq!(
1035 doc.to_string(),
1036 r#"[package]
1037name = "foo"
1038
1039[dev-dependencies]
1040other = "1"
1041
1042[build-dependencies]
1043workspace-hack = { path = "../workspace-hack" }
1044
1045[dependencies]
1046workspace-hack = { version = "0.1", path = "../workspace-hack" }
1047"#,
1048 "the dev-dependency line is dropped, the build-dependency line is kept"
1049 );
1050 }
1051
1052 #[test]
1053 fn add_dependency_rejects_non_table_dev_section() {
1054 let mut doc = parse(
1055 "dev-dependencies = 1
1056",
1057 );
1058 assert_eq!(
1059 add_dependency_to_document(&mut doc, "workspace-hack", hack_dep()),
1060 Err(NotATableError::Section {
1061 section: DependencySection::Dev
1062 }),
1063 );
1064 }
1065
1066 #[test]
1067 fn add_dependency_rejects_non_table_section() {
1068 let mut doc = parse(
1069 "dependencies = 1
1070",
1071 );
1072 assert_eq!(
1073 add_dependency_to_document(&mut doc, "workspace-hack", hack_dep()),
1074 Err(NotATableError::Section {
1075 section: DependencySection::Normal
1076 }),
1077 );
1078 }
1079
1080 #[test]
1081 fn remove_dependency_removes_line() {
1082 let mut doc = parse(
1083 r#"[dependencies]
1084workspace-hack = { path = "../workspace-hack" }
1085other = "1"
1086"#,
1087 );
1088 remove_dependency_from_document(&mut doc, "workspace-hack")
1089 .expect("[dependencies] is a table");
1090 assert_eq!(
1091 doc.to_string(),
1092 r#"[dependencies]
1093other = "1"
1094"#,
1095 );
1096 }
1097
1098 #[test]
1099 fn remove_dependency_removes_from_every_section() {
1100 let mut doc = parse(
1101 r#"[dependencies]
1102workspace-hack = { path = "../workspace-hack" }
1103
1104[dev-dependencies]
1105workspace-hack = { path = "../workspace-hack" }
1106other = "1"
1107
1108[build-dependencies]
1109workspace-hack = { path = "../workspace-hack" }
1110"#,
1111 );
1112 remove_dependency_from_document(&mut doc, "workspace-hack")
1113 .expect("all sections are tables");
1114 assert_eq!(
1115 doc.to_string(),
1116 r#"[dependencies]
1117
1118[dev-dependencies]
1119other = "1"
1120
1121[build-dependencies]
1122"#,
1123 );
1124 }
1125
1126 #[test]
1127 fn remove_dependency_removes_platform_specific_lines() {
1128 let mut doc = parse(
1129 r#"[dependencies]
1130other = "1"
1131
1132[target.'cfg(windows)'.dependencies]
1133workspace-hack = { path = "../workspace-hack" }
1134
1135[target.'cfg(unix)'.dependencies]
1136workspace-hack = { path = "../workspace-hack" }
1137other = "1"
1138
1139[target.'cfg(unix)'.dev-dependencies]
1140workspace-hack = { path = "../workspace-hack" }
1141
1142[target.'cfg(unix)'.build-dependencies]
1143workspace-hack = { path = "../workspace-hack" }
1144"#,
1145 );
1146 remove_dependency_from_document(&mut doc, "workspace-hack")
1147 .expect("all sections are tables");
1148 assert_eq!(
1149 doc.to_string(),
1150 r#"[dependencies]
1151other = "1"
1152
1153[target.'cfg(windows)'.dependencies]
1154
1155[target.'cfg(unix)'.dependencies]
1156other = "1"
1157
1158[target.'cfg(unix)'.dev-dependencies]
1159
1160[target.'cfg(unix)'.build-dependencies]
1161"#,
1162 );
1163 }
1164
1165 #[test]
1166 fn remove_dependency_rejects_non_table_platform_section() {
1167 let mut doc = parse("target = 1\n");
1168 assert_eq!(
1169 remove_dependency_from_document(&mut doc, "workspace-hack"),
1170 Err(NotATableError::Target),
1171 );
1172
1173 let mut doc = parse("[target]\n'cfg(unix)' = 1\n");
1174 assert_eq!(
1175 remove_dependency_from_document(&mut doc, "workspace-hack"),
1176 Err(NotATableError::Platform {
1177 platform: "cfg(unix)".to_owned()
1178 }),
1179 );
1180
1181 let mut doc = parse("[target.'cfg(unix)']\ndev-dependencies = 1\n");
1182 assert_eq!(
1183 remove_dependency_from_document(&mut doc, "workspace-hack"),
1184 Err(NotATableError::PlatformSection {
1185 platform: "cfg(unix)".to_owned(),
1186 section: DependencySection::Dev,
1187 }),
1188 );
1189 }
1190
1191 #[test]
1192 fn remove_dependency_without_section_is_noop() {
1193 let toml = r#"[package]
1194name = "foo"
1195"#;
1196 let mut doc = parse(toml);
1197 remove_dependency_from_document(&mut doc, "workspace-hack")
1198 .expect("missing sections are fine");
1199 assert_eq!(doc.to_string(), toml, "no sections are added");
1200 }
1201
1202 #[test]
1203 fn remove_dependency_rejects_non_table_section() {
1204 let mut doc = parse(
1205 "build-dependencies = 1
1206",
1207 );
1208 assert_eq!(
1209 remove_dependency_from_document(&mut doc, "workspace-hack"),
1210 Err(NotATableError::Section {
1211 section: DependencySection::Build
1212 }),
1213 );
1214 }
1215
1216 #[test]
1217 fn apply_rejects_missing_workspace_root() {
1218 let graph = JsonFixture::metadata1().graph();
1219 let error = WorkspaceOps::new(graph, [])
1220 .apply()
1221 .expect_err("the fixture's workspace root doesn't exist");
1222 assert_eq!(error.path(), graph.workspace().root());
1223 let ApplyErrorKind::CanonicalizeWorkspaceRoot { error: io_error } = error.kind() else {
1224 panic!("expected CanonicalizeWorkspaceRoot, found {error:?}");
1225 };
1226 assert_eq!(io_error.kind(), io::ErrorKind::NotFound);
1227 }
1228
1229 #[test]
1230 fn canonical_rel_path_returns_path_within_base() {
1231 let base = canonical_manifest_dir();
1232 let rel_path =
1233 canonical_rel_path("src".into(), &base).expect("src is within the crate directory");
1234 assert_eq!(rel_path, "src");
1235 }
1236
1237 #[test]
1238 fn canonical_rel_path_rejects_missing_path() {
1239 let base = canonical_manifest_dir();
1240 let error =
1241 canonical_rel_path("does-not-exist".into(), &base).expect_err("the path doesn't exist");
1242 assert_eq!(error.path(), base.join("does-not-exist"));
1243 let ApplyErrorKind::CanonicalizePath { error: io_error } = error.kind() else {
1244 panic!("expected CanonicalizePath, found {error:?}");
1245 };
1246 assert_eq!(io_error.kind(), io::ErrorKind::NotFound);
1247 }
1248
1249 #[test]
1250 fn canonical_rel_path_rejects_path_outside_base() {
1251 let base = canonical_manifest_dir();
1252 let error =
1253 canonical_rel_path("..".into(), &base).expect_err("the parent is outside the base");
1254 assert_eq!(error.path(), base.join(".."));
1255 let ApplyErrorKind::PathOutsideBase {
1256 canonical_path,
1257 canonical_base,
1258 } = error.kind()
1259 else {
1260 panic!("expected PathOutsideBase, found {error:?}");
1261 };
1262 assert_eq!(
1263 canonical_path,
1264 base.parent().expect("crate directory has a parent"),
1265 );
1266 assert_eq!(canonical_base, &base);
1267 }
1268
1269 #[test]
1270 fn create_new_crate_rejects_directory_under_file() {
1271 let base = canonical_manifest_dir();
1272 let error =
1273 WorkspaceOp::create_new_crate(&base, "Cargo.toml".into(), &file_map("src/lib.rs"))
1274 .expect_err("Cargo.toml is a file, so no directory can be created under it");
1275 assert_eq!(error.path(), base.join("Cargo.toml").join("src"));
1276 let ApplyErrorKind::CreateDirs { error: _ } = error.kind() else {
1277 panic!("expected CreateDirs, found {error:?}");
1278 };
1279 }
1280
1281 #[test]
1282 fn create_new_crate_rejects_missing_file_name() {
1283 let base = canonical_manifest_dir();
1284 let error = WorkspaceOp::create_new_crate(&base, "src".into(), &file_map(".."))
1285 .expect_err("`..` has no file name");
1286 assert_eq!(error.path(), "..");
1287 let ApplyErrorKind::MissingFileName = error.kind() else {
1288 panic!("expected MissingFileName, found {error:?}");
1289 };
1290 }
1291
1292 #[test]
1293 fn read_toml_rejects_missing_manifest() {
1294 let manifest_path = canonical_manifest_dir()
1295 .join("does-not-exist")
1296 .join("Cargo.toml");
1297 let error = read_toml(&manifest_path).expect_err("the manifest doesn't exist");
1298 assert_eq!(error.path(), manifest_path);
1299 let ApplyErrorKind::ReadManifest { error: io_error } = error.kind() else {
1300 panic!("expected ReadManifest, found {error:?}");
1301 };
1302 assert_eq!(io_error.kind(), io::ErrorKind::NotFound);
1303 }
1304
1305 #[test]
1306 fn parse_toml_rejects_invalid_toml() {
1307 let error =
1308 parse_toml("[workspace", "Cargo.toml".into()).expect_err("the table header is open");
1309 assert_eq!(error.path(), "Cargo.toml");
1310 let ApplyErrorKind::ParseManifest { error: _ } = error.kind() else {
1311 panic!("expected ParseManifest, found {error:?}");
1312 };
1313 }
1314
1315 #[test]
1316 fn write_contents_rejects_missing_parent() {
1317 let path = canonical_manifest_dir()
1318 .join("does-not-exist")
1319 .join("file.txt");
1320 let error = write_contents(&[], &path).expect_err("the parent directory doesn't exist");
1321 assert_eq!(error.path(), path);
1322 let ApplyErrorKind::WriteFile { error: _ } = error.kind() else {
1323 panic!("expected WriteFile, found {error:?}");
1324 };
1325 }
1326
1327 #[test]
1328 fn workspace_members_rejects_missing_workspace_section() {
1329 let mut doc = parse(
1330 r#"[package]
1331name = "foo"
1332"#,
1333 );
1334 assert_eq!(
1335 WorkspaceOp::get_workspace_members_array(&mut doc).expect_err("[workspace] is missing"),
1336 WorkspaceSectionError::NotFound,
1337 );
1338 }
1339
1340 #[test]
1341 fn workspace_members_rejects_non_table_workspace() {
1342 for (toml, expected_found) in [
1343 ("workspace = 1\n", "integer"),
1344 ("workspace = { members = [] }\n", "inline table"),
1345 ] {
1346 let mut doc = parse(toml);
1347 assert_eq!(
1348 WorkspaceOp::get_workspace_members_array(&mut doc)
1349 .expect_err("[workspace] is not a table"),
1350 WorkspaceSectionError::NotATable {
1351 found: expected_found
1352 },
1353 "for manifest {toml:?}",
1354 );
1355 }
1356 }
1357
1358 #[test]
1359 fn workspace_members_rejects_missing_members() {
1360 let mut doc = parse("[workspace]\n");
1361 assert_eq!(
1362 WorkspaceOp::get_workspace_members_array(&mut doc)
1363 .expect_err("workspace.members is missing"),
1364 WorkspaceSectionError::MembersNotFound,
1365 );
1366 }
1367
1368 #[test]
1369 fn workspace_members_rejects_non_array_members() {
1370 for (toml, expected_found) in [
1371 ("[workspace]\nmembers = \"foo\"\n", "string"),
1372 ("[workspace]\nmembers = { foo = 1 }\n", "inline table"),
1373 ("[workspace.members]\nfoo = 1\n", "table"),
1374 ("[[workspace.members]]\nfoo = 1\n", "array of tables"),
1375 ] {
1376 let mut doc = parse(toml);
1377 assert_eq!(
1378 WorkspaceOp::get_workspace_members_array(&mut doc)
1379 .expect_err("workspace.members is not an array"),
1380 WorkspaceSectionError::MembersNotAnArray {
1381 found: expected_found
1382 },
1383 "for manifest {toml:?}",
1384 );
1385 }
1386 }
1387
1388 #[test]
1389 fn add_to_workspace_members_rejects_non_string_member() {
1390 let mut doc = parse(
1391 r#"[workspace]
1392members = ["a", 1]
1393"#,
1394 );
1395 assert_eq!(
1396 WorkspaceOp::add_to_workspace_members(&mut doc, "b".into()),
1397 Err(WorkspaceSectionError::MemberNotAString {
1398 index: 1,
1399 found: "integer",
1400 }),
1401 );
1402 }
1403
1404 #[test]
1405 fn add_to_workspace_members_inserts_in_order() {
1406 let mut doc = parse(
1407 r#"[workspace]
1408members = [
1409 "a",
1410 "c",
1411]
1412"#,
1413 );
1414 WorkspaceOp::add_to_workspace_members(&mut doc, "b".into())
1415 .expect("workspace.members is an array of strings");
1416 assert_eq!(
1417 doc.to_string(),
1418 r#"[workspace]
1419members = [
1420 "a",
1421 "b",
1422 "c",
1423]
1424"#,
1425 );
1426 }
1427
1428 #[test]
1429 fn add_to_workspace_members_appends_after_last_member() {
1430 let mut doc = parse(
1431 r#"[workspace]
1432members = [
1433 "a",
1434 "b",
1435]
1436"#,
1437 );
1438 WorkspaceOp::add_to_workspace_members(&mut doc, "c".into())
1439 .expect("workspace.members is an array of strings");
1440 assert_eq!(
1441 doc.to_string(),
1442 r#"[workspace]
1443members = [
1444 "a",
1445 "b",
1446 "c",
1447]
1448"#,
1449 );
1450 }
1451
1452 #[test]
1453 fn add_to_workspace_members_skips_existing_member() {
1454 let toml = r#"[workspace]
1455members = [
1456 "a",
1457 "b",
1458]
1459"#;
1460 let mut doc = parse(toml);
1461 WorkspaceOp::add_to_workspace_members(&mut doc, "b".into())
1462 .expect("workspace.members is an array of strings");
1463 assert_eq!(doc.to_string(), toml, "the existing member is not repeated");
1464 }
1465
1466 #[test]
1467 fn apply_error_display_and_source() {
1468 let toml_error = "["
1469 .parse::<DocumentMut>()
1470 .expect_err("the table header is open");
1471 let cases = [
1472 (
1473 ApplyErrorKind::CanonicalizeWorkspaceRoot { error: io_error() },
1474 "for path Cargo.toml, unable to canonicalize workspace root",
1475 Some(io_error().to_string()),
1476 ),
1477 (
1478 ApplyErrorKind::CanonicalizePath { error: io_error() },
1479 "for path Cargo.toml, error canonicalizing path",
1480 Some(io_error().to_string()),
1481 ),
1482 (
1483 ApplyErrorKind::PathOutsideBase {
1484 canonical_path: "/elsewhere/Cargo.toml".into(),
1485 canonical_base: "/workspace".into(),
1486 },
1487 "for path Cargo.toml, canonical path is not within base path /workspace",
1488 None,
1489 ),
1490 (
1491 ApplyErrorKind::CreateDirs { error: io_error() },
1492 "for path Cargo.toml, error creating directories",
1493 Some(io_error().to_string()),
1494 ),
1495 (
1496 ApplyErrorKind::MissingFileName,
1497 "for path Cargo.toml, does not contain a file name",
1498 None,
1499 ),
1500 (
1501 ApplyErrorKind::ReadManifest { error: io_error() },
1502 "for path Cargo.toml, error reading TOML file",
1503 Some(io_error().to_string()),
1504 ),
1505 (
1506 ApplyErrorKind::ParseManifest {
1507 error: toml_error.clone(),
1508 },
1509 "for path Cargo.toml, error deserializing TOML file",
1510 Some(toml_error.to_string()),
1511 ),
1512 (
1513 ApplyErrorKind::WriteFile { error: io_error() },
1514 "for path Cargo.toml, error writing file",
1515 Some(io_error().to_string()),
1516 ),
1517 (
1518 ApplyErrorKind::WorkspaceSection {
1519 error: WorkspaceSectionError::NotFound,
1520 },
1521 "for path Cargo.toml, [workspace] section not found",
1522 None,
1523 ),
1524 (
1525 ApplyErrorKind::WorkspaceSection {
1526 error: WorkspaceSectionError::NotATable { found: "integer" },
1527 },
1528 "for path Cargo.toml, expected [workspace] to be a table, found integer",
1529 None,
1530 ),
1531 (
1532 ApplyErrorKind::WorkspaceSection {
1533 error: WorkspaceSectionError::MembersNotFound,
1534 },
1535 "for path Cargo.toml, workspace.members not found",
1536 None,
1537 ),
1538 (
1539 ApplyErrorKind::WorkspaceSection {
1540 error: WorkspaceSectionError::MembersNotAnArray { found: "string" },
1541 },
1542 "for path Cargo.toml, expected workspace.members to be an array, found string",
1543 None,
1544 ),
1545 (
1546 ApplyErrorKind::WorkspaceSection {
1547 error: WorkspaceSectionError::MemberNotAString {
1548 index: 1,
1549 found: "integer",
1550 },
1551 },
1552 "for path Cargo.toml, workspace.members contains non-strings",
1553 None,
1554 ),
1555 (
1556 ApplyErrorKind::NotATable {
1557 error: NotATableError::Section {
1558 section: DependencySection::Normal,
1559 },
1560 },
1561 "for path Cargo.toml, [dependencies] is not a table",
1562 None,
1563 ),
1564 (
1565 ApplyErrorKind::NotATable {
1566 error: NotATableError::Target,
1567 },
1568 "for path Cargo.toml, [target] is not a table",
1569 None,
1570 ),
1571 (
1572 ApplyErrorKind::NotATable {
1573 error: NotATableError::Platform {
1574 platform: "cfg(unix)".to_owned(),
1575 },
1576 },
1577 "for path Cargo.toml, [target.'cfg(unix)'] is not a table",
1578 None,
1579 ),
1580 (
1581 ApplyErrorKind::NotATable {
1582 error: NotATableError::PlatformSection {
1583 platform: "cfg(unix)".to_owned(),
1584 section: DependencySection::Dev,
1585 },
1586 },
1587 "for path Cargo.toml, [target.'cfg(unix)'.dev-dependencies] is not a table",
1588 None,
1589 ),
1590 ];
1591
1592 for (kind, expected_display, expected_source) in cases {
1593 let error = ApplyError::new("Cargo.toml", kind);
1594 assert_eq!(error.to_string(), expected_display);
1595 let chain: Vec<String> =
1596 iter::successors(Some(&error as &dyn error::Error), |error| error.source())
1597 .map(|error| error.to_string())
1598 .collect();
1599 let mut expected_chain = vec![expected_display.to_owned()];
1600 expected_chain.extend(expected_source);
1601 assert_eq!(
1602 chain, expected_chain,
1603 "each message appears once in the source chain",
1604 );
1605 }
1606 }
1607
1608 #[test]
1609 fn test_inline_table_for_add() {
1610 let versions = vec![
1611 ("1.2.3", "1", "1"),
1612 ("1.2.3-a.1+g456", "1.2.3-a.1+g456", "1.2.3-a.1"),
1613 ];
1614
1615 for (version, version_str, version_str_v3) in versions {
1616 let version: Version = version.parse().unwrap();
1617 let itable = WorkspaceOp::inline_table_for_add(
1618 &version,
1619 DepFormatVersion::V1,
1620 WorkspaceHackLineStyle::Full,
1621 "../../path".into(),
1622 );
1623 assert_eq!(
1624 itable.to_string(),
1625 "{ path = \"../../path\"}",
1626 "dep format v1 matches"
1627 );
1628
1629 let itable = WorkspaceOp::inline_table_for_add(
1630 &version,
1631 DepFormatVersion::V2,
1632 WorkspaceHackLineStyle::Full,
1633 "../../path".into(),
1634 );
1635 assert_eq!(
1636 itable.to_string(),
1637 format!("{{ version = \"{version_str}\", path = \"../../path\" }}"),
1638 "dep format v2 matches"
1639 );
1640
1641 let itable = WorkspaceOp::inline_table_for_add(
1642 &version,
1643 DepFormatVersion::V3,
1644 WorkspaceHackLineStyle::Full,
1645 "../../path".into(),
1646 );
1647 assert_eq!(
1648 itable.to_string(),
1649 format!("{{ version = \"{version_str_v3}\", path = \"../../path\" }}"),
1650 "dep format v3 matches"
1651 );
1652
1653 let itable = WorkspaceOp::inline_table_for_add(
1654 &version,
1655 DepFormatVersion::V4,
1656 WorkspaceHackLineStyle::VersionOnly,
1657 "../../path".into(),
1658 );
1659 assert_eq!(
1660 itable.to_string(),
1661 format!("{{ version = \"{version_str_v3}\" }}"),
1662 "version only matches"
1663 );
1664
1665 let itable = WorkspaceOp::inline_table_for_add(
1666 &version,
1667 DepFormatVersion::V4,
1668 WorkspaceHackLineStyle::WorkspaceDotted,
1669 "../../path".into(),
1670 );
1671 let mut document = DocumentMut::new();
1672 document
1673 .as_table_mut()
1674 .insert("workspace-hack", Item::Value(Value::InlineTable(itable)));
1675 assert_eq!(
1676 document.to_string(),
1677 "workspace-hack.workspace = true\n",
1678 "workspace dep matches"
1679 );
1680 }
1681 }
1682}