Skip to main content

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    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/// Represents a set of write operations to the workspace.
22#[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    /// Returns a displayer for the workspace operations.
45    #[inline]
46    pub fn display<'ops>(&'ops self) -> WorkspaceOpsDisplay<'g, 'a, 'ops> {
47        WorkspaceOpsDisplay::new(self)
48    }
49
50    /// Returns true if no workspace operations are specified.
51    #[inline]
52    pub fn is_empty(&self) -> bool {
53        self.ops.is_empty()
54    }
55
56    /// Apply these workspace operations.
57    ///
58    /// Returns an error if any operations failed to complete.
59    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                // Now that the crate has been created, we can canonicalize it.
105                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    // ---
149    // Helper methods
150    // ---
151
152    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            // Create parent directories if necessary.
160            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            // Write out the file.
169            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            // idx can be within the array (0..members.len()) or at the end (members.len() + 1).
200            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                    // Insert the crate path before the first element greater than it. If the list
218                    // is kept in alphabetical order, this works out correctly.
219                    match path.cmp(crate_path) {
220                        Ordering::Greater => {
221                            add(members, idx);
222                            written = true;
223                            break;
224                        }
225                        Ordering::Equal => {
226                            // The crate path already exists -- skip it.
227                            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        // Find the location of the new path (relative) with respect to the package path.
295        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                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
317                // event that a published workspace-hack version has a minor bump in it.
318                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                    // Previous versions of `cargo hakari` accidentally missed adding the space to the end
329                    // of the line. Newer versions of toml_edit do that automatically, so restore the old
330                    // behavior.
331                    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                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
342                // event that a published workspace-hack version has a minor bump in it.
343                let version_str = format!("{}", VersionDisplay::new(version, false, false));
344                itable.insert("version", version_str.into());
345                itable
346            }
347            WorkspaceHackLineStyle::WorkspaceDotted => {
348                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
349                // event that a published workspace-hack version has a minor bump in it.
350                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// ---
368// Manifest edits
369// ---
370
371/// The name of a `Cargo.toml` dependency section.
372#[derive(Clone, Copy, Debug, Eq, PartialEq)]
373pub enum DependencySection {
374    /// A normal dependency in the `[dependencies]` or `[target.*.dependencies]`
375    /// sections.
376    Normal,
377    /// A dev dependency in the `[dev-dependencies]` or
378    /// `[target.*.dev-dependencies]` sections.
379    Dev,
380    /// A build dependency in the `[build-dependencies]` or
381    /// `[target.*.build-dependencies]` sections.
382    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/// An error while editing a manifest document: an entry that should be a
398/// table isn't one.
399#[derive(Clone, Debug, Eq, PartialEq, Error)]
400#[non_exhaustive]
401pub enum NotATableError {
402    /// A top-level dependency section.
403    #[error("[{}] is not a table", .section.key())]
404    Section {
405        /// The section that is not a table.
406        section: DependencySection,
407    },
408    /// The `[target]` table.
409    #[error("[target] is not a table")]
410    Target,
411    /// A `[target.<platform>]` entry.
412    #[error("[target.'{platform}'] is not a table")]
413    Platform {
414        /// The platform key, without TOML quoting or escapes.
415        platform: String,
416    },
417    /// A `[target.<platform>.<section>]` entry.
418    #[error("[target.'{platform}'.{}] is not a table", .section.key())]
419    PlatformSection {
420        /// The platform key, without TOML quoting or escapes.
421        platform: String,
422        /// The section under the platform that is not a table.
423        section: DependencySection,
424    },
425}
426
427/// Adds or replaces the entry for `name` in `[dependencies]`, creating the
428/// section if needed.
429///
430/// Also drops `name` from `[dev-dependencies]`. A dev dependency is a strict
431/// subset of a normal one.
432///
433/// `[build-dependencies]` is left alone since it has meaningfully different
434/// semantics. (We may want to also remove them in the future, though.)
435fn 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
448/// Removes the entry for `name` from every dependency section it appears in,
449/// including the platform-specific sections under `[target]`.
450fn remove_dependency_from_document(
451    doc: &mut DocumentMut,
452    name: &str,
453) -> Result<(), NotATableError> {
454    // TODO: someone might have added the workspace-hack package under a different name.
455    // Handle that if someone complains.
456    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
491/// Returns the given dependency section, or `None` if it doesn't exist.
492fn 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        // Add the table.
524        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
544// Always write out paths with forward slashes, including on Windows.
545fn with_forward_slashes(path: &Utf8Path) -> Utf8PathBuf {
546    let components: Vec<_> = path.iter().collect();
547    components.join("/").into()
548}
549
550// ---
551// Path functions
552// ---
553
554fn canonical_rel_path(
555    path: &Utf8Path,
556    canonical_base: &Utf8Path,
557) -> Result<Utf8PathBuf, ApplyError> {
558    let abs_path = canonical_base.join(path);
559    // Canonicalize the path now to remove .. etc.
560    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            // This can happen under some symlink scenarios.
567            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
578// ---
579// Read/write functions
580// ---
581
582fn 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/// An error that occurred while writing out changes to a workspace.
615#[derive(Debug)]
616pub struct ApplyError {
617    path: Utf8PathBuf,
618    kind: Box<ApplyErrorKind>,
619}
620
621impl ApplyError {
622    /// Returns the path at which the error occurred.
623    #[inline]
624    pub fn path(&self) -> &Utf8Path {
625        &self.path
626    }
627
628    /// Returns the kind of error that occurred.
629    #[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        // The kind's message is part of this error's Display, so skip over
651        // the kind in the source chain to avoid printing its message twice.
652        error::Error::source(&*self.kind)
653    }
654}
655
656/// The kind of error that occurred while applying workspace operations.
657///
658/// Returned by [`ApplyError::kind`]. To access the corresponding path, use
659/// [`ApplyError::path`].
660#[derive(Debug, Error)]
661#[non_exhaustive]
662pub enum ApplyErrorKind {
663    /// The workspace root could not be canonicalized.
664    #[error("unable to canonicalize workspace root")]
665    CanonicalizeWorkspaceRoot {
666        /// The underlying error.
667        #[source]
668        error: io::Error,
669    },
670
671    /// A path, when joined to the canonical workspace root, could not be canonicalized.
672    #[error("error canonicalizing path")]
673    CanonicalizePath {
674        /// The underlying error.
675        #[source]
676        error: io::Error,
677    },
678
679    /// The canonical form of the path is not within the canonical workspace root.
680    ///
681    /// This can happen due to symlinks, `..` components, or an absolute path.
682    #[error("canonical path is not within base path {canonical_base}")]
683    PathOutsideBase {
684        /// The canonical form of the path.
685        canonical_path: Utf8PathBuf,
686        /// The canonical base the path was expected to be within.
687        canonical_base: Utf8PathBuf,
688    },
689
690    /// The directory at the path, or one of its parents, could not be created.
691    #[error("error creating directories")]
692    CreateDirs {
693        /// The underlying error.
694        #[source]
695        error: io::Error,
696    },
697
698    /// The path of a file to be created does not end in a file name.
699    ///
700    /// Here, [`ApplyError::path`] is relative to the new crate's directory.
701    #[error("does not contain a file name")]
702    MissingFileName,
703
704    /// The manifest could not be read.
705    #[error("error reading TOML file")]
706    ReadManifest {
707        /// The underlying error.
708        #[source]
709        error: io::Error,
710    },
711
712    /// The manifest is not valid TOML.
713    #[error("error deserializing TOML file")]
714    ParseManifest {
715        /// The underlying error.
716        #[source]
717        error: TomlError,
718    },
719
720    /// The file could not be written.
721    #[error("error writing file")]
722    WriteFile {
723        /// The underlying error.
724        #[source]
725        error: io::Error,
726    },
727
728    /// The root manifest's `[workspace]` section does not have the shape needed
729    /// to add a member to it.
730    #[error(transparent)]
731    WorkspaceSection {
732        /// What is wrong with the section.
733        error: WorkspaceSectionError,
734    },
735
736    /// An entry in the manifest that should be a table is not one.
737    #[error(transparent)]
738    NotATable {
739        /// Which entry is not a table.
740        error: NotATableError,
741    },
742}
743
744/// An error occurred modifying the root manifest's `[workspace]` section.
745///
746/// Part of [`ApplyErrorKind::WorkspaceSection`].
747#[derive(Clone, Debug, Eq, PartialEq, Error)]
748#[non_exhaustive]
749pub enum WorkspaceSectionError {
750    /// The root manifest has no `[workspace]` section.
751    #[error("[workspace] section not found")]
752    NotFound,
753
754    /// `[workspace]` in the root manifest is not a table.
755    ///
756    /// Currently, an inline table is rejected as well.
757    #[error("expected [workspace] to be a table, found {found}")]
758    NotATable {
759        /// The name of the TOML type found instead, as returned by toml_edit's
760        /// [`Item::type_name`].
761        found: &'static str,
762    },
763
764    /// The root manifest does not have a `workspace.members` field.
765    #[error("workspace.members not found")]
766    MembersNotFound,
767
768    /// `workspace.members` in the root manifest is not an array.
769    #[error("expected workspace.members to be an array, found {found}")]
770    MembersNotAnArray {
771        /// The name of the TOML type found instead, as returned by toml_edit's
772        /// [`Item::type_name`].
773        found: &'static str,
774    },
775
776    /// An element of `workspace.members` in the root manifest is not a string.
777    #[error("workspace.members contains non-strings")]
778    MemberNotAString {
779        /// zero-based index of the element.
780        index: usize,
781        /// The name of the TOML type found instead, as returned by toml_edit's
782        /// [`Item::type_name`].
783        found: &'static str,
784    },
785}
786
787/// A display formatter for [`WorkspaceOps`].
788#[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    /// Adds ANSI color codes to the output.
803    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}