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 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    #[cfg(test)]
39    pub(crate) fn ops(&self) -> &[WorkspaceOp<'g, 'a>] {
40        &self.ops
41    }
42
43    /// Returns a displayer for the workspace operations.
44    #[inline]
45    pub fn display<'ops>(&'ops self) -> WorkspaceOpsDisplay<'g, 'a, 'ops> {
46        WorkspaceOpsDisplay::new(self)
47    }
48
49    /// Returns true if no workspace operations are specified.
50    #[inline]
51    pub fn is_empty(&self) -> bool {
52        self.ops.is_empty()
53    }
54
55    /// Apply these workspace operations.
56    ///
57    /// Returns an error if any operations failed to complete.
58    pub fn apply(&self) -> Result<(), ApplyError> {
59        let workspace_root = self.graph.workspace().root();
60        let canonical_workspace_root = workspace_root.canonicalize_utf8().map_err(|error| {
61            ApplyError::io(
62                "unable to canonicalize workspace root",
63                workspace_root.to_owned(),
64                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)
111                        .map_err(|err| ApplyError::io("error creating directories", parent, err))?;
112                    write_contents(contents, &abs_path)?;
113                }
114
115                Self::add_to_root_toml(canonical_workspace_root, &crate_path)
116            }
117            WorkspaceOp::AddDependency {
118                name,
119                crate_path,
120                version,
121                dep_format,
122                line_style,
123                add_to,
124            } => {
125                let crate_path = canonical_rel_path(crate_path, canonical_workspace_root)?;
126                for package in add_to.packages(DependencyDirection::Reverse) {
127                    Self::add_to_cargo_toml(
128                        name,
129                        version,
130                        &crate_path,
131                        *dep_format,
132                        *line_style,
133                        package,
134                    )?;
135                }
136                Ok(())
137            }
138            WorkspaceOp::RemoveDependency { name, remove_from } => {
139                for package in remove_from.packages(DependencyDirection::Reverse) {
140                    Self::remove_from_cargo_toml(name, package)?;
141                }
142                Ok(())
143            }
144        }
145    }
146
147    // ---
148    // Helper methods
149    // ---
150
151    fn create_new_crate(
152        workspace_root: &Utf8Path,
153        crate_path: &Utf8Path,
154        files: &BTreeMap<Cow<'_, Utf8Path>, Cow<'_, [u8]>>,
155    ) -> Result<(), ApplyError> {
156        let abs_path = workspace_root.join(crate_path);
157        for (path, contents) in files {
158            // Create parent directories if necessary.
159            let mut dir_path = match path.parent() {
160                Some(parent) => abs_path.join(parent),
161                None => abs_path.clone(),
162            };
163            std::fs::create_dir_all(&dir_path)
164                .map_err(|err| ApplyError::io("error creating directories", &dir_path, err))?;
165
166            // Write out the file.
167            dir_path.push(
168                path.file_name().ok_or_else(|| {
169                    ApplyError::misc("does not contain a file name", path.as_ref())
170                })?,
171            );
172            write_contents(contents, &dir_path)?;
173        }
174        Ok(())
175    }
176
177    fn add_to_root_toml(
178        workspace_root: &Utf8Path,
179        crate_path: &Utf8Path,
180    ) -> Result<(), ApplyError> {
181        let root_toml_path = workspace_root.join("Cargo.toml");
182
183        let mut doc = read_toml(&root_toml_path)?;
184        let members = Self::get_workspace_members_array(&root_toml_path, &mut doc)?;
185
186        let add = |members: &mut Array, idx: usize| {
187            // idx can be within the array (0..members.len()) or at the end (members.len() + 1).
188            let existing = if idx < members.len() {
189                members.get(idx).expect("valid idx")
190            } else {
191                members.get(members.len() - 1).expect("valid idx")
192            };
193
194            let write_path = with_forward_slashes(crate_path).into_string();
195            let write_path = decorate(existing, write_path);
196            members.insert_formatted(idx, write_path);
197        };
198
199        let mut written = false;
200        for idx in 0..members.len() {
201            let member = members.get(idx).expect("valid idx");
202            match member.as_str() {
203                Some(path) => {
204                    let path = Utf8Path::new(path);
205                    // Insert the crate path before the first element greater than it. If the list
206                    // is kept in alphabetical order, this works out correctly.
207                    match path.cmp(crate_path) {
208                        Ordering::Greater => {
209                            add(members, idx);
210                            written = true;
211                            break;
212                        }
213                        Ordering::Equal => {
214                            // The crate path already exists -- skip it.
215                            written = true;
216                            break;
217                        }
218                        Ordering::Less => {}
219                    }
220                }
221                None => {
222                    return Err(ApplyError::misc(
223                        "workspace.members contains non-strings",
224                        root_toml_path,
225                    ));
226                }
227            }
228        }
229
230        if !written {
231            add(members, members.len());
232        }
233
234        write_document(&doc, &root_toml_path)
235    }
236
237    fn get_workspace_members_array<'doc>(
238        root_toml_path: &Utf8Path,
239        doc: &'doc mut DocumentMut,
240    ) -> Result<&'doc mut Array, ApplyError> {
241        let doc_table = doc.as_table_mut();
242        let workspace_table = match doc_table.get_mut("workspace") {
243            Some(Item::Table(workspace_table)) => workspace_table,
244            Some(other) => {
245                return Err(ApplyError::misc(
246                    format!(
247                        "expected [workspace] to be a table, found {}",
248                        other.type_name()
249                    ),
250                    root_toml_path,
251                ));
252            }
253            None => {
254                return Err(ApplyError::misc(
255                    "[workspace] section not found",
256                    root_toml_path,
257                ));
258            }
259        };
260
261        let members = match workspace_table.get_mut("members") {
262            Some(Item::Value(members)) => match members.as_array_mut() {
263                Some(members) => members,
264                None => {
265                    return Err(ApplyError::misc(
266                        "workspace.members is not an array",
267                        root_toml_path,
268                    ));
269                }
270            },
271            Some(other) => {
272                return Err(ApplyError::misc(
273                    format!(
274                        "expected workspace.members to be an array, found {}",
275                        other.type_name()
276                    ),
277                    root_toml_path,
278                ));
279            }
280            None => {
281                return Err(ApplyError::misc(
282                    "workspace.members not found",
283                    root_toml_path,
284                ));
285            }
286        };
287        Ok(members)
288    }
289
290    fn add_to_cargo_toml(
291        name: &str,
292        version: &Version,
293        crate_path: &Utf8Path,
294        dep_format: DepFormatVersion,
295        line_style: WorkspaceHackLineStyle,
296        package: PackageMetadata<'g>,
297    ) -> Result<(), ApplyError> {
298        let manifest_path = package.manifest_path();
299        let mut doc = read_toml(manifest_path)?;
300
301        let package_path = package
302            .source()
303            .workspace_path()
304            .expect("package should be in workspace");
305        // Find the location of the new path (relative) with respect to the package path.
306        let path = pathdiff::diff_utf8_paths(crate_path, package_path)
307            .expect("both new_path and package_path are relative");
308
309        let path_table = Self::inline_table_for_add(version, dep_format, line_style, &path);
310
311        add_dependency_to_document(&mut doc, name, path_table)
312            .map_err(|error| ApplyError::misc(error.message(), manifest_path))?;
313
314        write_document(&doc, manifest_path)
315    }
316
317    fn inline_table_for_add(
318        version: &Version,
319        dep_format: DepFormatVersion,
320        line_style: WorkspaceHackLineStyle,
321        path: &Utf8Path,
322    ) -> InlineTable {
323        let mut itable = InlineTable::new();
324
325        match line_style {
326            WorkspaceHackLineStyle::Full => {
327                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
328                // event that a published workspace-hack version has a minor bump in it.
329                let version_str = format!(
330                    "{}",
331                    VersionDisplay::new(version, false, dep_format < DepFormatVersion::V3)
332                );
333                if dep_format >= DepFormatVersion::V2 {
334                    itable.insert("version", version_str.into());
335                }
336
337                let mut path = Formatted::new(with_forward_slashes(path).into_string());
338                if dep_format == DepFormatVersion::V1 {
339                    // Previous versions of `cargo hakari` accidentally missed adding the space to the end
340                    // of the line. Newer versions of toml_edit do that automatically, so restore the old
341                    // behavior.
342                    path.decor_mut().set_suffix("");
343                }
344                itable.insert("path", Value::String(path));
345
346                if dep_format == DepFormatVersion::V2 {
347                    itable.fmt();
348                }
349                itable
350            }
351            WorkspaceHackLineStyle::VersionOnly => {
352                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
353                // event that a published workspace-hack version has a minor bump in it.
354                let version_str = format!("{}", VersionDisplay::new(version, false, false));
355                itable.insert("version", version_str.into());
356                itable
357            }
358            WorkspaceHackLineStyle::WorkspaceDotted => {
359                // Pass in exact_versions = false because we don't want unnecessary churn in the unlikely
360                // event that a published workspace-hack version has a minor bump in it.
361                itable.insert("workspace", true.into());
362                itable.set_dotted(true);
363                itable
364            }
365        }
366    }
367
368    fn remove_from_cargo_toml(name: &str, package: PackageMetadata<'g>) -> Result<(), ApplyError> {
369        let manifest_path = package.manifest_path();
370        let mut doc = read_toml(manifest_path)?;
371        remove_dependency_from_document(&mut doc, name)
372            .map_err(|error| ApplyError::misc(error.message(), manifest_path))?;
373
374        write_document(&doc, manifest_path)
375    }
376}
377
378// ---
379// Manifest edits
380// ---
381
382/// The name of a `Cargo.toml` dependency section.
383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
384enum DependencySection {
385    Normal,
386    Dev,
387    Build,
388}
389
390impl DependencySection {
391    const ALL: [Self; 3] = [Self::Normal, Self::Dev, Self::Build];
392
393    fn key(self) -> &'static str {
394        match self {
395            DependencySection::Normal => "dependencies",
396            DependencySection::Dev => "dev-dependencies",
397            DependencySection::Build => "build-dependencies",
398        }
399    }
400}
401
402/// An error while editing a manifest document: an entry that should be a
403/// table isn't one.
404#[derive(Clone, Debug, Eq, PartialEq)]
405enum NotATableError {
406    /// A top-level dependency section.
407    Section { section: DependencySection },
408    /// The `[target]` table.
409    Target,
410    /// A `[target.<platform>]` entry.
411    Platform { platform: String },
412    /// A `[target.<platform>.<section>]` entry.
413    PlatformSection {
414        platform: String,
415        section: DependencySection,
416    },
417}
418
419impl NotATableError {
420    fn message(&self) -> String {
421        match self {
422            NotATableError::Section { section } => {
423                format!("[{}] is not a table", section.key())
424            }
425            NotATableError::Target => "[target] is not a table".to_owned(),
426            NotATableError::Platform { platform } => {
427                format!("[target.'{platform}'] is not a table")
428            }
429            NotATableError::PlatformSection { platform, section } => {
430                format!("[target.'{platform}'.{}] is not a table", section.key())
431            }
432        }
433    }
434}
435
436/// Adds or replaces the entry for `name` in `[dependencies]`, creating the
437/// section if needed.
438///
439/// Also drops `name` from `[dev-dependencies]`. A dev dependency is a strict
440/// subset of a normal one.
441///
442/// `[build-dependencies]` is left alone since it has meaningfully different
443/// semantics. (We may want to also remove them in the future, though.)
444fn add_dependency_to_document(
445    doc: &mut DocumentMut,
446    name: &str,
447    dep: InlineTable,
448) -> Result<(), NotATableError> {
449    let dep_table = get_or_insert_dependency_section(doc, DependencySection::Normal)?;
450    dep_table.insert(name, Item::Value(Value::InlineTable(dep)));
451    if let Some(dev_table) = get_dependency_section(doc, DependencySection::Dev)? {
452        dev_table.remove(name);
453    }
454    Ok(())
455}
456
457/// Removes the entry for `name` from every dependency section it appears in,
458/// including the platform-specific sections under `[target]`.
459fn remove_dependency_from_document(
460    doc: &mut DocumentMut,
461    name: &str,
462) -> Result<(), NotATableError> {
463    // TODO: someone might have added the workspace-hack package under a different name.
464    // Handle that if someone complains.
465    for section in DependencySection::ALL {
466        if let Some(dep_table) = get_dependency_section(doc, section)? {
467            dep_table.remove(name);
468        }
469    }
470
471    let Some(target_item) = doc.as_table_mut().get_mut("target") else {
472        return Ok(());
473    };
474    let Some(target_table) = target_item.as_table_like_mut() else {
475        return Err(NotATableError::Target);
476    };
477    for (platform, platform_item) in target_table.iter_mut() {
478        let platform = platform.get();
479        let Some(platform_table) = platform_item.as_table_like_mut() else {
480            return Err(NotATableError::Platform {
481                platform: platform.to_owned(),
482            });
483        };
484        for section in DependencySection::ALL {
485            let Some(section_item) = platform_table.get_mut(section.key()) else {
486                continue;
487            };
488            let Some(dep_table) = section_item.as_table_like_mut() else {
489                return Err(NotATableError::PlatformSection {
490                    platform: platform.to_owned(),
491                    section,
492                });
493            };
494            dep_table.remove(name);
495        }
496    }
497    Ok(())
498}
499
500/// Returns the given dependency section, or `None` if it doesn't exist.
501fn get_dependency_section(
502    doc: &mut DocumentMut,
503    section: DependencySection,
504) -> Result<Option<&mut dyn TableLike>, NotATableError> {
505    let key = section.key();
506    match doc.as_table_mut().get_mut(key) {
507        Some(item) => match item.as_table_like_mut() {
508            Some(table) => Ok(Some(table)),
509            None => Err(NotATableError::Section { section }),
510        },
511        None => Ok(None),
512    }
513}
514
515fn get_or_insert_dependency_section(
516    doc: &mut DocumentMut,
517    section: DependencySection,
518) -> Result<&mut dyn TableLike, NotATableError> {
519    let key = section.key();
520    let doc_table = doc.as_table_mut();
521
522    if doc_table.contains_key(key) {
523        match doc_table
524            .get_mut(key)
525            .expect("just checked for presence of section")
526            .as_table_like_mut()
527        {
528            Some(table) => Ok(table),
529            None => Err(NotATableError::Section { section }),
530        }
531    } else {
532        // Add the table.
533        let mut new_table = Table::new();
534        new_table.set_implicit(true);
535        doc_table.insert(key, Item::Table(new_table));
536        let table = doc_table
537            .get_mut(key)
538            .expect("was just inserted")
539            .as_table_like_mut()
540            .expect("was just inserted");
541        Ok(table)
542    }
543}
544
545fn decorate(existing: &Value, new: impl Into<Value>) -> Value {
546    let decor = existing.decor();
547    new.into().decorated(
548        decor.prefix().cloned().unwrap_or_default(),
549        decor.suffix().cloned().unwrap_or_default(),
550    )
551}
552
553// Always write out paths with forward slashes, including on Windows.
554fn with_forward_slashes(path: &Utf8Path) -> Utf8PathBuf {
555    let components: Vec<_> = path.iter().collect();
556    components.join("/").into()
557}
558
559// ---
560// Path functions
561// ---
562
563fn canonical_rel_path(
564    path: &Utf8Path,
565    canonical_base: &Utf8Path,
566) -> Result<Utf8PathBuf, ApplyError> {
567    let abs_path = canonical_base.join(path);
568    // Canonicalize the path now to remove .. etc.
569    let canonical_path = abs_path
570        .canonicalize_utf8()
571        .map_err(|err| ApplyError::io("error canonicalizing path", &abs_path, err))?;
572    canonical_path
573        .strip_prefix(canonical_base)
574        .map_err(|_| {
575            // This can happen under some symlink scenarios.
576            ApplyError::misc(
577                format!("canonical path is not within base path {canonical_base}"),
578                &abs_path,
579            )
580        })
581        .map(|p| p.to_owned())
582}
583
584// ---
585// Read/write functions
586// ---
587
588fn read_toml(manifest_path: &Utf8Path) -> Result<DocumentMut, ApplyError> {
589    let toml = fs::read_to_string(manifest_path)
590        .map_err(|err| ApplyError::io("error reading TOML file", manifest_path, err))?;
591    toml.parse::<DocumentMut>()
592        .map_err(|err| ApplyError::toml("error deserializing TOML file", manifest_path, err))
593}
594
595fn write_contents(contents: &[u8], path: &Utf8Path) -> Result<(), ApplyError> {
596    write_atomic(path, |file| file.write_all(contents))
597}
598
599fn write_document(document: &DocumentMut, path: &Utf8Path) -> Result<(), ApplyError> {
600    write_atomic(path, |file| write!(file, "{document}"))
601}
602
603fn write_atomic(
604    path: &Utf8Path,
605    cb: impl FnOnce(&mut fs::File) -> Result<(), io::Error>,
606) -> Result<(), ApplyError> {
607    let atomic_file = AtomicFile::new(path, OverwriteBehavior::AllowOverwrite);
608    match atomic_file.write(cb) {
609        Ok(()) => Ok(()),
610        Err(atomicwrites::Error::Internal(err)) | Err(atomicwrites::Error::User(err)) => {
611            Err(ApplyError::io("error writing file", path, err))
612        }
613    }
614}
615
616/// An error that occurred while writing out changes to a workspace.
617#[derive(Debug)]
618pub struct ApplyError {
619    message: String,
620    path: Utf8PathBuf,
621    kind: Box<ApplyErrorKind>,
622}
623
624impl ApplyError {
625    /// Returns the message corresponding to the error.
626    #[inline]
627    pub fn message(&self) -> &str {
628        &self.message
629    }
630
631    /// Returns the path at which the error occurred.
632    #[inline]
633    pub fn path(&self) -> &Utf8Path {
634        &self.path
635    }
636
637    // ---
638    // Helper methods
639    // ---
640    fn io(message: impl Into<String>, path: impl Into<Utf8PathBuf>, err: io::Error) -> Self {
641        Self {
642            message: message.into(),
643            path: path.into(),
644            kind: Box::new(ApplyErrorKind::Io { err }),
645        }
646    }
647
648    fn toml(
649        message: impl Into<String>,
650        path: impl Into<Utf8PathBuf>,
651        err: toml_edit::TomlError,
652    ) -> Self {
653        Self {
654            message: message.into(),
655            path: path.into(),
656            kind: Box::new(ApplyErrorKind::Toml { err }),
657        }
658    }
659
660    fn misc(message: impl Into<String>, path: impl Into<Utf8PathBuf>) -> Self {
661        Self {
662            message: message.into(),
663            path: path.into(),
664            kind: Box::new(ApplyErrorKind::Misc),
665        }
666    }
667}
668
669impl fmt::Display for ApplyError {
670    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
671        write!(f, "for path {}, {}", self.path, self.message)
672    }
673}
674
675impl error::Error for ApplyError {
676    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
677        match &*self.kind {
678            ApplyErrorKind::Io { err } => Some(err),
679            ApplyErrorKind::Toml { err } => Some(err),
680            ApplyErrorKind::Misc => None,
681        }
682    }
683}
684
685#[derive(Debug)]
686enum ApplyErrorKind {
687    Io { err: io::Error },
688    Toml { err: TomlError },
689    Misc,
690}
691
692/// A display formatter for [`WorkspaceOps`].
693#[derive(Clone, Debug)]
694pub struct WorkspaceOpsDisplay<'g, 'a, 'ops> {
695    ops: &'ops WorkspaceOps<'g, 'a>,
696    styles: Box<Styles>,
697}
698
699impl<'g, 'a, 'ops> WorkspaceOpsDisplay<'g, 'a, 'ops> {
700    fn new(ops: &'ops WorkspaceOps<'g, 'a>) -> Self {
701        Self {
702            ops,
703            styles: Box::default(),
704        }
705    }
706
707    /// Adds ANSI color codes to the output.
708    pub fn colorize(&mut self) -> &mut Self {
709        self.styles.colorize();
710        self
711    }
712}
713
714impl fmt::Display for WorkspaceOpsDisplay<'_, '_, '_> {
715    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
716        let workspace_root = self.ops.graph.workspace().root();
717        let workspace_root_manifest = workspace_root.join("Cargo.toml");
718        for op in &self.ops.ops {
719            match op {
720                WorkspaceOp::NewCrate {
721                    crate_path,
722                    files,
723                    root_files,
724                } => {
725                    write!(
726                        f,
727                        "* {} at {}",
728                        "create crate".style(self.styles.create_bold_style),
729                        crate_path.style(self.styles.create_bold_style),
730                    )?;
731                    if !files.is_empty() {
732                        writeln!(f, ", with files:")?;
733                        for file in files.keys() {
734                            writeln!(f, "   - {}", file.style(self.styles.create_style))?;
735                        }
736                    } else {
737                        writeln!(f)?;
738                    }
739                    writeln!(
740                        f,
741                        "* {} at {} to {}",
742                        "add crate".style(self.styles.add_bold_style),
743                        crate_path.style(self.styles.add_style),
744                        workspace_root_manifest.style(self.styles.add_to_style),
745                    )?;
746                    if !root_files.is_empty() {
747                        writeln!(
748                            f,
749                            "* {} at workspace root:",
750                            "create files".style(self.styles.create_bold_style)
751                        )?;
752                        for file in root_files.keys() {
753                            writeln!(f, "   - {}", file.style(self.styles.create_style))?;
754                        }
755                    }
756                }
757                WorkspaceOp::AddDependency {
758                    name,
759                    version,
760                    crate_path,
761                    dep_format: _,
762                    line_style: _,
763                    add_to,
764                } => {
765                    writeln!(
766                        f,
767                        "* {} {} v{} (at path {}) to packages:",
768                        "add or update dependency".style(self.styles.add_bold_style),
769                        name.style(self.styles.add_style),
770                        version.style(self.styles.add_style),
771                        crate_path.style(self.styles.add_style),
772                    )?;
773                    for (name, path) in package_names_paths(add_to) {
774                        writeln!(
775                            f,
776                            "   - {} (at path {})",
777                            name.style(self.styles.add_to_bold_style),
778                            path.style(self.styles.add_to_style)
779                        )?;
780                    }
781                }
782                WorkspaceOp::RemoveDependency { name, remove_from } => {
783                    writeln!(
784                        f,
785                        "* {} {} from packages:",
786                        "remove dependency".style(self.styles.remove_bold_style),
787                        name.style(self.styles.remove_style),
788                    )?;
789                    for (name, path) in package_names_paths(remove_from) {
790                        writeln!(
791                            f,
792                            "   - {} (at path {})",
793                            name.style(self.styles.remove_from_bold_style),
794                            path.style(self.styles.remove_from_style)
795                        )?;
796                    }
797                }
798            }
799        }
800        Ok(())
801    }
802}
803
804#[derive(Clone, Debug, Default)]
805struct Styles {
806    create_style: Style,
807    add_style: Style,
808    add_to_style: Style,
809    remove_style: Style,
810    remove_from_style: Style,
811    create_bold_style: Style,
812    add_bold_style: Style,
813    add_to_bold_style: Style,
814    remove_bold_style: Style,
815    remove_from_bold_style: Style,
816}
817
818impl Styles {
819    fn colorize(&mut self) {
820        self.create_style = Style::new().green();
821        self.add_style = Style::new().cyan();
822        self.add_to_style = Style::new().blue();
823        self.remove_style = Style::new().red();
824        self.remove_from_style = Style::new().purple();
825        self.create_bold_style = self.create_style.bold();
826        self.add_bold_style = self.add_style.bold();
827        self.add_to_bold_style = self.add_to_style.bold();
828        self.remove_bold_style = self.remove_style.bold();
829        self.remove_from_bold_style = self.remove_from_style.bold();
830    }
831}
832
833fn package_names_paths<'g>(package_set: &PackageSet<'g>) -> Vec<(&'g str, &'g Utf8Path)> {
834    let mut package_names_paths: Vec<_> = package_set
835        .packages(DependencyDirection::Forward)
836        .map(|package| {
837            (
838                package.name(),
839                package
840                    .source()
841                    .workspace_path()
842                    .expect("workspace package"),
843            )
844        })
845        .collect();
846    package_names_paths.sort_unstable();
847    package_names_paths
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853
854    fn hack_dep() -> InlineTable {
855        WorkspaceOp::inline_table_for_add(
856            &"0.1.0".parse().expect("valid version"),
857            DepFormatVersion::V4,
858            WorkspaceHackLineStyle::Full,
859            "../workspace-hack".into(),
860        )
861    }
862
863    fn parse(toml: &str) -> DocumentMut {
864        toml.parse().expect("test manifest is valid TOML")
865    }
866
867    #[test]
868    fn add_dependency_creates_section() {
869        let mut doc = parse(
870            r#"[package]
871name = "foo"
872"#,
873        );
874        add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
875            .expect("[dependencies] is created");
876        assert_eq!(
877            doc.to_string(),
878            r#"[package]
879name = "foo"
880
881[dependencies]
882workspace-hack = { version = "0.1", path = "../workspace-hack" }
883"#,
884        );
885    }
886
887    #[test]
888    fn add_dependency_replaces_existing_line() {
889        let mut doc = parse(
890            r#"[dependencies]
891workspace-hack = { path = "../old" }
892other = "1"
893"#,
894        );
895        add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
896            .expect("[dependencies] is a table");
897        assert_eq!(
898            doc.to_string(),
899            r#"[dependencies]
900workspace-hack = { version = "0.1", path = "../workspace-hack" }
901other = "1"
902"#,
903            "the existing line is replaced in place"
904        );
905    }
906
907    #[test]
908    fn add_dependency_drops_dev_dependency_line() {
909        let mut doc = parse(
910            r#"[package]
911name = "foo"
912
913[dev-dependencies]
914workspace-hack = { path = "../workspace-hack" }
915other = "1"
916
917[build-dependencies]
918workspace-hack = { path = "../workspace-hack" }
919"#,
920        );
921        add_dependency_to_document(&mut doc, "workspace-hack", hack_dep())
922            .expect("[dependencies] is created");
923        assert_eq!(
924            doc.to_string(),
925            r#"[package]
926name = "foo"
927
928[dev-dependencies]
929other = "1"
930
931[build-dependencies]
932workspace-hack = { path = "../workspace-hack" }
933
934[dependencies]
935workspace-hack = { version = "0.1", path = "../workspace-hack" }
936"#,
937            "the dev-dependency line is dropped, the build-dependency line is kept"
938        );
939    }
940
941    #[test]
942    fn add_dependency_rejects_non_table_dev_section() {
943        let mut doc = parse(
944            "dev-dependencies = 1
945",
946        );
947        assert_eq!(
948            add_dependency_to_document(&mut doc, "workspace-hack", hack_dep()),
949            Err(NotATableError::Section {
950                section: DependencySection::Dev
951            }),
952        );
953    }
954
955    #[test]
956    fn add_dependency_rejects_non_table_section() {
957        let mut doc = parse(
958            "dependencies = 1
959",
960        );
961        assert_eq!(
962            add_dependency_to_document(&mut doc, "workspace-hack", hack_dep()),
963            Err(NotATableError::Section {
964                section: DependencySection::Normal
965            }),
966        );
967    }
968
969    #[test]
970    fn remove_dependency_removes_line() {
971        let mut doc = parse(
972            r#"[dependencies]
973workspace-hack = { path = "../workspace-hack" }
974other = "1"
975"#,
976        );
977        remove_dependency_from_document(&mut doc, "workspace-hack")
978            .expect("[dependencies] is a table");
979        assert_eq!(
980            doc.to_string(),
981            r#"[dependencies]
982other = "1"
983"#,
984        );
985    }
986
987    #[test]
988    fn remove_dependency_removes_from_every_section() {
989        let mut doc = parse(
990            r#"[dependencies]
991workspace-hack = { path = "../workspace-hack" }
992
993[dev-dependencies]
994workspace-hack = { path = "../workspace-hack" }
995other = "1"
996
997[build-dependencies]
998workspace-hack = { path = "../workspace-hack" }
999"#,
1000        );
1001        remove_dependency_from_document(&mut doc, "workspace-hack")
1002            .expect("all sections are tables");
1003        assert_eq!(
1004            doc.to_string(),
1005            r#"[dependencies]
1006
1007[dev-dependencies]
1008other = "1"
1009
1010[build-dependencies]
1011"#,
1012        );
1013    }
1014
1015    #[test]
1016    fn remove_dependency_removes_platform_specific_lines() {
1017        let mut doc = parse(
1018            r#"[dependencies]
1019other = "1"
1020
1021[target.'cfg(windows)'.dependencies]
1022workspace-hack = { path = "../workspace-hack" }
1023
1024[target.'cfg(unix)'.dependencies]
1025workspace-hack = { path = "../workspace-hack" }
1026other = "1"
1027
1028[target.'cfg(unix)'.dev-dependencies]
1029workspace-hack = { path = "../workspace-hack" }
1030
1031[target.'cfg(unix)'.build-dependencies]
1032workspace-hack = { path = "../workspace-hack" }
1033"#,
1034        );
1035        remove_dependency_from_document(&mut doc, "workspace-hack")
1036            .expect("all sections are tables");
1037        assert_eq!(
1038            doc.to_string(),
1039            r#"[dependencies]
1040other = "1"
1041
1042[target.'cfg(windows)'.dependencies]
1043
1044[target.'cfg(unix)'.dependencies]
1045other = "1"
1046
1047[target.'cfg(unix)'.dev-dependencies]
1048
1049[target.'cfg(unix)'.build-dependencies]
1050"#,
1051        );
1052    }
1053
1054    #[test]
1055    fn remove_dependency_rejects_non_table_platform_section() {
1056        let mut doc = parse("target = 1\n");
1057        assert_eq!(
1058            remove_dependency_from_document(&mut doc, "workspace-hack"),
1059            Err(NotATableError::Target),
1060        );
1061
1062        let mut doc = parse("[target]\n'cfg(unix)' = 1\n");
1063        assert_eq!(
1064            remove_dependency_from_document(&mut doc, "workspace-hack"),
1065            Err(NotATableError::Platform {
1066                platform: "cfg(unix)".to_owned()
1067            }),
1068        );
1069
1070        let mut doc = parse("[target.'cfg(unix)']\ndev-dependencies = 1\n");
1071        assert_eq!(
1072            remove_dependency_from_document(&mut doc, "workspace-hack"),
1073            Err(NotATableError::PlatformSection {
1074                platform: "cfg(unix)".to_owned(),
1075                section: DependencySection::Dev,
1076            }),
1077        );
1078    }
1079
1080    #[test]
1081    fn remove_dependency_without_section_is_noop() {
1082        let toml = r#"[package]
1083name = "foo"
1084"#;
1085        let mut doc = parse(toml);
1086        remove_dependency_from_document(&mut doc, "workspace-hack")
1087            .expect("missing sections are fine");
1088        assert_eq!(doc.to_string(), toml, "no sections are added");
1089    }
1090
1091    #[test]
1092    fn remove_dependency_rejects_non_table_section() {
1093        let mut doc = parse(
1094            "build-dependencies = 1
1095",
1096        );
1097        assert_eq!(
1098            remove_dependency_from_document(&mut doc, "workspace-hack"),
1099            Err(NotATableError::Section {
1100                section: DependencySection::Build
1101            }),
1102        );
1103    }
1104
1105    #[test]
1106    fn test_inline_table_for_add() {
1107        let versions = vec![
1108            ("1.2.3", "1", "1"),
1109            ("1.2.3-a.1+g456", "1.2.3-a.1+g456", "1.2.3-a.1"),
1110        ];
1111
1112        for (version, version_str, version_str_v3) in versions {
1113            let version: Version = version.parse().unwrap();
1114            let itable = WorkspaceOp::inline_table_for_add(
1115                &version,
1116                DepFormatVersion::V1,
1117                WorkspaceHackLineStyle::Full,
1118                "../../path".into(),
1119            );
1120            assert_eq!(
1121                itable.to_string(),
1122                "{ path = \"../../path\"}",
1123                "dep format v1 matches"
1124            );
1125
1126            let itable = WorkspaceOp::inline_table_for_add(
1127                &version,
1128                DepFormatVersion::V2,
1129                WorkspaceHackLineStyle::Full,
1130                "../../path".into(),
1131            );
1132            assert_eq!(
1133                itable.to_string(),
1134                format!("{{ version = \"{version_str}\", path = \"../../path\" }}"),
1135                "dep format v2 matches"
1136            );
1137
1138            let itable = WorkspaceOp::inline_table_for_add(
1139                &version,
1140                DepFormatVersion::V3,
1141                WorkspaceHackLineStyle::Full,
1142                "../../path".into(),
1143            );
1144            assert_eq!(
1145                itable.to_string(),
1146                format!("{{ version = \"{version_str_v3}\", path = \"../../path\" }}"),
1147                "dep format v3 matches"
1148            );
1149
1150            let itable = WorkspaceOp::inline_table_for_add(
1151                &version,
1152                DepFormatVersion::V4,
1153                WorkspaceHackLineStyle::VersionOnly,
1154                "../../path".into(),
1155            );
1156            assert_eq!(
1157                itable.to_string(),
1158                format!("{{ version = \"{version_str_v3}\" }}"),
1159                "version only matches"
1160            );
1161
1162            let itable = WorkspaceOp::inline_table_for_add(
1163                &version,
1164                DepFormatVersion::V4,
1165                WorkspaceHackLineStyle::WorkspaceDotted,
1166                "../../path".into(),
1167            );
1168            let mut document = DocumentMut::new();
1169            document
1170                .as_table_mut()
1171                .insert("workspace-hack", Item::Value(Value::InlineTable(itable)));
1172            assert_eq!(
1173                document.to_string(),
1174                "workspace-hack.workspace = true\n",
1175                "workspace dep matches"
1176            );
1177        }
1178    }
1179}