hakari/cli_ops/
workspace_ops.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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/// Represents a set of write operations to the workspace.
21#[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    /// Returns a displayer for the workspace operations.
39    #[inline]
40    pub fn display<'ops>(&'ops self) -> WorkspaceOpsDisplay<'g, 'a, 'ops> {
41        WorkspaceOpsDisplay::new(self)
42    }
43
44    /// Returns true if no workspace operations are specified.
45    #[inline]
46    pub fn is_empty(&self) -> bool {
47        self.ops.is_empty()
48    }
49
50    /// Apply these workspace operations.
51    ///
52    /// Returns an error if any operations failed to complete.
53    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                // Now that the crate has been created, we can canonicalize it.
100                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    // ---
143    // Helper methods
144    // ---
145
146    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            // Create parent directories if necessary.
154            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            // Write out the file.
162            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            // idx can be within the array (0..members.len()) or at the end (members.len() + 1).
183            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                    // Insert the crate path before the first element greater than it. If the list
201                    // is kept in alphabetical order, this works out correctly.
202                    match path.cmp(crate_path) {
203                        Ordering::Greater => {
204                            add(members, idx);
205                            written = true;
206                            break;
207                        }
208                        Ordering::Equal => {
209                            // The crate path already exists -- skip it.
210                            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        // Find the location of the new path (relative) with respect to the package path.
302        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                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
323                // event that a published workspace-hack version has a minor bump in it.
324                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                    // Previous versions of `cargo hakari` accidentally missed adding the space to the end
335                    // of the line. Newer versions of toml_edit do that automatically, so restore the old
336                    // behavior.
337                    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                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
348                // event that a published workspace-hack version has a minor bump in it.
349                let version_str = format!("{}", VersionDisplay::new(version, false, false));
350                itable.insert("version", version_str.into());
351                itable
352            }
353            WorkspaceHackLineStyle::WorkspaceDotted => {
354                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
355                // event that a published workspace-hack version has a minor bump in it.
356                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        // TODO: someone might have added the workspace-hack package under a different name.
368        // Handle that if someone complains.
369        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            // Add the dependencies table.
394            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
415// Always write out paths with forward slashes, including on Windows.
416fn with_forward_slashes(path: &Utf8Path) -> Utf8PathBuf {
417    let components: Vec<_> = path.iter().collect();
418    components.join("/").into()
419}
420
421// ---
422// Path functions
423// ---
424
425fn canonical_rel_path(
426    path: &Utf8Path,
427    canonical_base: &Utf8Path,
428) -> Result<Utf8PathBuf, ApplyError> {
429    let abs_path = canonical_base.join(path);
430    // Canonicalize the path now to remove .. etc.
431    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            // This can happen under some symlink scenarios.
438            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
446// ---
447// Read/write functions
448// ---
449
450fn 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/// An error that occurred while writing out changes to a workspace.
479#[derive(Debug)]
480pub struct ApplyError {
481    message: String,
482    path: Utf8PathBuf,
483    kind: Box<ApplyErrorKind>,
484}
485
486impl ApplyError {
487    /// Returns the message corresponding to the error.
488    #[inline]
489    pub fn message(&self) -> &str {
490        &self.message
491    }
492
493    /// Returns the path at which the error occurred.
494    #[inline]
495    pub fn path(&self) -> &Utf8Path {
496        &self.path
497    }
498
499    // ---
500    // Helper methods
501    // ---
502    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/// A display formatter for [`WorkspaceOps`].
555#[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    /// Adds ANSI color codes to the output.
570    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}