Skip to main content

hakari/
toml_out.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Facilities for writing out TOML data from a Hakari map.
5
6#[cfg(feature = "cli-support")]
7use crate::summaries::HakariBuilderSummary;
8use crate::{
9    DepFormatVersion,
10    hakari::{HakariBuilder, OutputMap},
11    helpers::VersionDisplay,
12};
13use ahash::AHashMap;
14use camino::{Utf8Component, Utf8Path, Utf8PathBuf, Utf8Prefix};
15use cfg_if::cfg_if;
16use guppy::{
17    PackageId,
18    errors::TargetSpecError,
19    graph::{ExternalSource, GitReq, PackageMetadata, PackageSource, cargo::BuildPlatform},
20};
21use iddqd::{IdOrdItem, IdOrdMap, id_upcast};
22use std::{
23    borrow::Cow,
24    collections::HashSet,
25    error, fmt,
26    hash::{Hash, Hasher},
27};
28use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
29use twox_hash::XxHash64;
30
31/// Options for Hakari TOML output.
32#[derive(Clone, Debug)]
33pub struct HakariOutputOptions {
34    pub(crate) exact_versions: bool,
35    pub(crate) absolute_paths: bool,
36    #[cfg(feature = "cli-support")]
37    pub(crate) builder_summary: bool,
38}
39
40impl HakariOutputOptions {
41    /// Creates a new instance with default settings.
42    ///
43    /// The default settings are:
44    /// * do not output exact versions
45    /// * do not output a summary of builder options
46    pub fn new() -> Self {
47        Self {
48            exact_versions: false,
49            absolute_paths: false,
50            #[cfg(feature = "cli-support")]
51            builder_summary: false,
52        }
53    }
54
55    /// If set to true, outputs exact versions in package version fields.
56    ///
57    /// By default, Hakari outputs the loosest possible version requirement that matches the
58    /// specified package. This is generally appropriate  if the `Cargo.lock` file isn't checked in,
59    /// and there's no automated process keeping the dependencies up-to-date.
60    ///
61    /// In some cases one may wish to output the exact versions selected instead. For example:
62    /// * The `Cargo.lock` file is checked in and all developers have matching lockfiles.
63    /// * A tool like [Dependabot](https://dependabot.com/) is configured to update `Cargo.toml`
64    ///   files to their latest versions.
65    ///
66    /// ## Note
67    ///
68    /// If set to true, and the `Cargo.lock` file isn't checked in, Hakari's output will vary based
69    /// on the repository it is run in. Most of the time this isn't desirable.
70    pub fn set_exact_versions(&mut self, exact_versions: bool) -> &mut Self {
71        self.exact_versions = exact_versions;
72        self
73    }
74
75    /// If set to true, outputs absolute paths for path dependencies.
76    ///
77    /// By default, `hakari` outputs relative paths, for example:
78    ///
79    /// ```toml
80    /// path-dependency = { path = "../../path-dependency" }
81    /// ```
82    ///
83    /// If set to true, `hakari` will output absolute paths, for example:
84    ///
85    /// ```toml
86    /// path-dependency = { path = "/path/to/path-dependency" }
87    /// ```
88    ///
89    /// In most situations, relative paths lead to better results. Use with care.
90    ///
91    /// ## Notes
92    ///
93    /// If set to false, a Hakari package must be specified in [`HakariBuilder`](HakariBuilder). If
94    /// it isn't and Hakari needs to output a relative path,
95    /// [`TomlOutError::PathWithoutHakari`](TomlOutError::PathWithoutHakari) will be returned.
96    pub fn set_absolute_paths(&mut self, absolute_paths: bool) -> &mut Self {
97        self.absolute_paths = absolute_paths;
98        self
99    }
100
101    /// If set to true, outputs a summary of the builder options used to generate the `Hakari`, as
102    /// TOML comments.
103    ///
104    /// The options are output as a header in the Hakari section:
105    ///
106    /// ```toml
107    /// # resolver = "2"
108    /// # platforms = [...]
109    /// ...
110    /// ```
111    ///
112    /// Requires the `cli-support` feature to be enabled.
113    #[cfg(feature = "cli-support")]
114    pub fn set_builder_summary(&mut self, builder_summary: bool) -> &mut Self {
115        self.builder_summary = builder_summary;
116        self
117    }
118}
119
120impl Default for HakariOutputOptions {
121    fn default() -> Self {
122        Self::new()
123    }
124}
125
126/// An error that occurred while writing out TOML.
127#[derive(Debug)]
128#[non_exhaustive]
129pub enum TomlOutError {
130    /// An error occurred while serializing platform information.
131    Platform(TargetSpecError),
132
133    /// An error occurred while serializing TOML.
134    ///
135    /// This option is only present if the `cli-support` feature is enabled.
136    #[cfg(feature = "cli-support")]
137    Toml {
138        /// A context string for the error.
139        context: Cow<'static, str>,
140
141        /// The underlying error.
142        err: toml::ser::Error,
143    },
144
145    /// An error occurred while writing to a `fmt::Write` instance.
146    FmtWrite(fmt::Error),
147
148    /// Attempted to output a path dependency, but a Hakari package wasn't provided to the builder.
149    ///
150    /// If any path dependencies need to be unified, the location of the Hakari package must be
151    /// specified so that a relative path can be displayed.
152    PathWithoutHakari {
153        /// The package ID that Hakari tried to write out a dependency line for.
154        package_id: PackageId,
155
156        /// The relative path to the package from the root of the workspace.
157        rel_path: Utf8PathBuf,
158    },
159
160    /// An external source wasn't recognized by guppy.
161    UnrecognizedExternal {
162        /// The package ID that Hakari tried to write out a dependency line for.
163        package_id: PackageId,
164
165        /// The source string that wasn't recognized.
166        source: String,
167    },
168
169    /// An external registry was found and wasn't passed into [`HakariOutputOptions`].
170    UnrecognizedRegistry {
171        /// The package ID that Hakari tried to write out a dependency line for.
172        package_id: PackageId,
173
174        /// The registry URL that wasn't recognized.
175        registry_url: String,
176    },
177}
178
179/// The name a dependency is written out under in the workspace-hack's
180/// `Cargo.toml`.
181#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
182pub enum TomlName<'g> {
183    /// The package name, used when the workspace-hack contains one version
184    /// of the package.
185    Plain(&'g str),
186
187    /// The package name with a hash appended, used when the workspace-hack
188    /// contains more than one version of the package.
189    Hashed(Box<str>),
190}
191
192impl TomlName<'_> {
193    /// Returns the name as a string.
194    pub fn as_str(&self) -> &str {
195        match self {
196            Self::Plain(name) => name,
197            Self::Hashed(name) => name,
198        }
199    }
200}
201
202impl fmt::Display for TomlName<'_> {
203    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
204        f.write_str(self.as_str())
205    }
206}
207
208/// A dependency line in the workspace-hack's `Cargo.toml`, keyed by the name
209/// it is written out under.
210#[derive(Clone, Debug)]
211pub struct TomlNameEntry<'g> {
212    toml_name: TomlName<'g>,
213    package: PackageMetadata<'g>,
214}
215
216impl<'g> TomlNameEntry<'g> {
217    /// Returns the name this dependency is written out under.
218    pub fn toml_name(&self) -> &TomlName<'g> {
219        &self.toml_name
220    }
221
222    /// Returns the package this dependency line refers to.
223    pub fn package(&self) -> &PackageMetadata<'g> {
224        &self.package
225    }
226}
227
228impl<'g> IdOrdItem for TomlNameEntry<'g> {
229    type Key<'a>
230        = &'a str
231    where
232        Self: 'a;
233
234    fn key(&self) -> Self::Key<'_> {
235        self.toml_name.as_str()
236    }
237
238    id_upcast!();
239}
240
241/// Returns a map from dependency names as present in the workspace `Cargo.toml` to their
242/// corresponding package metadatas.
243pub(crate) fn toml_name_map<'g>(
244    output_map: &OutputMap<'g>,
245    dep_format: DepFormatVersion,
246) -> IdOrdMap<TomlNameEntry<'g>> {
247    let mut packages_by_name: AHashMap<&'g str, AHashMap<_, _>> = AHashMap::new();
248    for vals in output_map.values() {
249        for (&package_id, (package, _)) in vals {
250            packages_by_name
251                .entry(package.name())
252                .or_default()
253                .insert(package_id, package);
254        }
255    }
256
257    // A hashed name colliding with another TOML name would already produce a
258    // duplicate key in the written-out Cargo.toml, so treat it as an invariant
259    // violation here rather than silently dropping a line. (We should consider
260    // handling this better in the future.)
261    let mut toml_name_map = IdOrdMap::new();
262    for (name, packages) in packages_by_name {
263        if packages.len() > 1 {
264            // Make hashed names for each package.
265            for (_, package) in packages {
266                let hashed_name = make_hashed_name(package, dep_format);
267                toml_name_map
268                    .insert_unique(TomlNameEntry {
269                        toml_name: TomlName::Hashed(hashed_name.into_boxed_str()),
270                        package: *package,
271                    })
272                    .expect("hashed names are unique within the workspace-hack");
273            }
274        } else {
275            toml_name_map
276                .insert_unique(TomlNameEntry {
277                    toml_name: TomlName::Plain(name),
278                    package: *packages.into_values().next().expect("at least 1 element"),
279                })
280                .expect("package names are unique within the workspace-hack");
281        }
282    }
283
284    toml_name_map
285}
286
287impl From<TargetSpecError> for TomlOutError {
288    fn from(err: TargetSpecError) -> Self {
289        TomlOutError::Platform(err)
290    }
291}
292
293impl From<fmt::Error> for TomlOutError {
294    fn from(err: fmt::Error) -> Self {
295        TomlOutError::FmtWrite(err)
296    }
297}
298
299impl fmt::Display for TomlOutError {
300    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
301        match self {
302            TomlOutError::Platform(_) => write!(f, "while serializing platform information"),
303            #[cfg(feature = "cli-support")]
304            TomlOutError::Toml { context, .. } => write!(f, "while serializing TOML: {context}"),
305            TomlOutError::FmtWrite(_) => write!(f, "while writing to fmt::Write"),
306            TomlOutError::PathWithoutHakari {
307                package_id,
308                rel_path,
309            } => write!(
310                f,
311                "for path dependency '{package_id}', no Hakari package was specified (relative path {rel_path})",
312            ),
313            TomlOutError::UnrecognizedExternal { package_id, source } => write!(
314                f,
315                "for third-party dependency '{package_id}', unrecognized external source {source}",
316            ),
317            TomlOutError::UnrecognizedRegistry {
318                package_id,
319                registry_url,
320            } => {
321                write!(
322                    f,
323                    "for third-party dependency '{package_id}', unrecognized registry at URL {registry_url}",
324                )
325            }
326        }
327    }
328}
329
330impl error::Error for TomlOutError {
331    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
332        match self {
333            TomlOutError::Platform(err) => Some(err),
334            #[cfg(feature = "cli-support")]
335            TomlOutError::Toml { err, .. } => Some(err),
336            TomlOutError::FmtWrite(err) => Some(err),
337            TomlOutError::PathWithoutHakari { .. }
338            | TomlOutError::UnrecognizedExternal { .. }
339            | TomlOutError::UnrecognizedRegistry { .. } => None,
340        }
341    }
342}
343
344pub(crate) fn write_toml(
345    builder: &HakariBuilder<'_>,
346    output_map: &OutputMap<'_>,
347    options: &HakariOutputOptions,
348    dep_format: DepFormatVersion,
349    mut out: impl fmt::Write,
350) -> Result<(), TomlOutError> {
351    cfg_if! {
352        if #[cfg(feature = "cli-support")] {
353            if options.builder_summary {
354                let summary = HakariBuilderSummary::new(builder)?;
355                summary.write_comment(&mut out)?;
356                writeln!(out)?;
357            }
358        }
359    }
360
361    let mut packages_by_name: AHashMap<&str, HashSet<_>> = AHashMap::new();
362    for vals in output_map.values() {
363        for (&package_id, (package, _)) in vals {
364            packages_by_name
365                .entry(package.name())
366                .or_default()
367                .insert(package_id);
368        }
369    }
370
371    let hakari_path = builder.hakari_package().map(|package| {
372        package
373            .source()
374            .workspace_path()
375            .expect("hakari package is in workspace")
376    });
377
378    let mut document = DocumentMut::new();
379
380    // Remove the leading newline from the first visual table to match what older versions of
381    // hakari did.
382    let mut first_element = true;
383
384    for (key, vals) in output_map {
385        let dep_table_parent = match key.platform_idx {
386            Some(idx) => {
387                let target_table = get_or_insert_table(document.as_table_mut(), "target");
388                get_or_insert_table(target_table, builder.platforms[idx].triple_str())
389            }
390            None => document.as_table_mut(),
391        };
392
393        let dep_table = match key.build_platform {
394            BuildPlatform::Target => get_or_insert_table(dep_table_parent, "dependencies"),
395            BuildPlatform::Host => get_or_insert_table(dep_table_parent, "build-dependencies"),
396        };
397
398        if first_element {
399            dep_table.decor_mut().set_prefix("");
400            first_element = false;
401        }
402
403        for (dep, all_features) in vals.values() {
404            let mut itable = InlineTable::new();
405
406            let name: Cow<str> = if packages_by_name[dep.name()].len() > 1 {
407                itable.insert("package", dep.name().into());
408                make_hashed_name(dep, dep_format).into()
409            } else {
410                dep.name().into()
411            };
412
413            let source = dep.source();
414            if source.is_crates_io() {
415                itable.insert(
416                    "version",
417                    format!(
418                        "{}",
419                        VersionDisplay::new(
420                            dep.version(),
421                            options.exact_versions,
422                            dep_format < DepFormatVersion::V3
423                        )
424                    )
425                    .into(),
426                );
427            } else {
428                match source {
429                    PackageSource::Workspace(path) | PackageSource::Path(path) => {
430                        // PackageSource::Workspace shouldn't be possible unless the Hakari map
431                        // was fiddled with. Regardless, we can handle it fine.
432                        let path_out = if options.absolute_paths {
433                            // TODO: canonicalize paths here, removing .. etc? tricky if the path is
434                            // missing (as in tests)
435                            builder.graph().workspace().root().join(path)
436                        } else {
437                            let hakari_path =
438                                hakari_path.ok_or_else(|| TomlOutError::PathWithoutHakari {
439                                    package_id: dep.id().clone(),
440                                    rel_path: path.to_path_buf(),
441                                })?;
442                            pathdiff::diff_utf8_paths(path, hakari_path)
443                                .expect("both hakari_path and path are relative")
444                        }
445                        .into_string();
446
447                        cfg_if! {
448                            if #[cfg(windows)] {
449                                // TODO: is replacing \\ with / totally safe on Windows? Might run
450                                // into issues with UNC paths.
451                                let path_out = path_out.replace("\\", "/");
452                                itable.insert("path", path_out.into());
453                            } else {
454                                itable.insert("path", path_out.into());
455                            }
456                        };
457                    }
458                    PackageSource::External(s) => match source.parse_external() {
459                        Some(ExternalSource::Git {
460                            repository, req, ..
461                        }) => {
462                            itable.insert("git", repository.into());
463                            match req {
464                                GitReq::Branch(branch) => {
465                                    itable.insert("branch", branch.into());
466                                }
467                                GitReq::Tag(tag) => {
468                                    itable.insert("tag", tag.into());
469                                }
470                                GitReq::Rev(rev) => {
471                                    itable.insert("rev", rev.into());
472                                }
473                                GitReq::Default => {}
474                                _ => {
475                                    return Err(TomlOutError::UnrecognizedExternal {
476                                        package_id: dep.id().clone(),
477                                        source: s.to_string(),
478                                    });
479                                }
480                            };
481                        }
482                        Some(ExternalSource::Registry(registry_url)) => {
483                            let registry =
484                                builder.registries.get2(registry_url).ok_or_else(|| {
485                                    TomlOutError::UnrecognizedRegistry {
486                                        package_id: dep.id().clone(),
487                                        registry_url: registry_url.to_owned(),
488                                    }
489                                })?;
490                            itable.insert(
491                                "version",
492                                format!(
493                                    "{}",
494                                    VersionDisplay::new(
495                                        dep.version(),
496                                        options.exact_versions,
497                                        dep_format < DepFormatVersion::V3
498                                    )
499                                )
500                                .into(),
501                            );
502                            itable.insert("registry", registry.name.clone().into());
503                        }
504                        Some(ExternalSource::Sparse(registry_url)) => {
505                            let registry = builder
506                                .registries
507                                .get2(
508                                    format!("{}{}", ExternalSource::SPARSE_PLUS, registry_url)
509                                        .as_str(),
510                                )
511                                .ok_or_else(|| TomlOutError::UnrecognizedRegistry {
512                                    package_id: dep.id().clone(),
513                                    registry_url: registry_url.to_owned(),
514                                })?;
515                            itable.insert(
516                                "version",
517                                format!(
518                                    "{}",
519                                    VersionDisplay::new(
520                                        dep.version(),
521                                        options.exact_versions,
522                                        dep_format < DepFormatVersion::V3
523                                    )
524                                )
525                                .into(),
526                            );
527                            itable.insert("registry", registry.name.clone().into());
528                        }
529                        _ => {
530                            return Err(TomlOutError::UnrecognizedExternal {
531                                package_id: dep.id().clone(),
532                                source: s.to_string(),
533                            });
534                        }
535                    },
536                }
537            };
538
539            if !all_features.contains(&"default") {
540                itable.insert("default-features", false.into());
541            }
542
543            let feature_array: Array = all_features
544                .iter()
545                .filter_map(|&label| {
546                    // Only care about named features here.
547                    match label {
548                        "default" => None,
549                        feature_name => Some(feature_name),
550                    }
551                })
552                .collect();
553            if !feature_array.is_empty() {
554                itable.insert("features", feature_array.into());
555            }
556
557            itable.fmt();
558
559            dep_table.insert(name.as_ref(), Item::Value(Value::InlineTable(itable)));
560        }
561
562        if dep_format >= DepFormatVersion::V4 {
563            dep_table.sort_values();
564        }
565    }
566
567    // Match formatting with older versions of hakari: if the document is non-empty, print out a
568    // newline at the end.
569    write!(out, "{document}")?;
570    if !document.is_empty() {
571        writeln!(out)?;
572    }
573
574    Ok(())
575}
576
577/// Generate a unique, stable package name from the metadata.
578fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
579    // Use a fixed seed to ensure stable hashes.
580    let mut hasher = XxHash64::default();
581    // Use the minimal version so that a bump from e.g. 0.2.5 to 0.2.6 doesn't change the hash.
582    let minimal_version = format!(
583        "{}",
584        // We use a slightly different hashing scheme for V3+ formats (this is more correct but we
585        // don't want to change hashes for folks on older versions.).
586        VersionDisplay::new(dep.version(), false, dep_format < DepFormatVersion::V3)
587    );
588    minimal_version.hash(&mut hasher);
589    hash_package_source(dep.source(), &mut hasher);
590    let hash = hasher.finish();
591
592    format!("{}-{:x}", dep.name(), hash)
593}
594
595// Hash algorithm for `PackageSource`.
596//
597// This matches what `derive(Hash)` on `PackageSource` produced with camino
598// 1.2.1. That was always a bit of an abuse of the `Hash` impl, since its exact
599// result for a type isn't part of the public API. Well, that's coming back to
600// bite us now, since camino 1.2.3 changed `Utf8Path`'s `Hash` impl.
601fn hash_package_source<H: Hasher>(source: PackageSource<'_>, hasher: &mut H) {
602    match source {
603        PackageSource::Workspace(path) => {
604            hash_discriminant(0, hasher);
605            hash_utf8_path(path, hasher);
606        }
607        PackageSource::Path(path) => {
608            hash_discriminant(1, hasher);
609            hash_utf8_path(path, hasher);
610        }
611        PackageSource::External(source) => {
612            hash_discriminant(2, hasher);
613            hash_str(source, hasher);
614        }
615    }
616}
617
618fn hash_utf8_path<H: Hasher>(path: &Utf8Path, hasher: &mut H) {
619    for component in path.components() {
620        match component {
621            Utf8Component::Prefix(prefix) => {
622                hash_discriminant(0, hasher);
623                hash_utf8_prefix(prefix.kind(), hasher);
624            }
625            Utf8Component::RootDir => hash_discriminant(1, hasher),
626            Utf8Component::CurDir => hash_discriminant(2, hasher),
627            Utf8Component::ParentDir => hash_discriminant(3, hasher),
628            Utf8Component::Normal(name) => {
629                hash_discriminant(4, hasher);
630                hash_str(name, hasher);
631            }
632        }
633    }
634}
635
636fn hash_utf8_prefix<H: Hasher>(prefix: Utf8Prefix<'_>, hasher: &mut H) {
637    match prefix {
638        Utf8Prefix::Verbatim(name) => {
639            hash_discriminant(0, hasher);
640            hash_os_str(name, hasher);
641        }
642        Utf8Prefix::VerbatimUNC(server, share) => {
643            hash_discriminant(1, hasher);
644            hash_os_str(server, hasher);
645            hash_os_str(share, hasher);
646        }
647        Utf8Prefix::VerbatimDisk(drive) => {
648            hash_discriminant(2, hasher);
649            hasher.write_u8(drive);
650        }
651        Utf8Prefix::DeviceNS(device) => {
652            hash_discriminant(3, hasher);
653            hash_os_str(device, hasher);
654        }
655        Utf8Prefix::UNC(server, share) => {
656            hash_discriminant(4, hasher);
657            hash_os_str(server, hasher);
658            hash_os_str(share, hasher);
659        }
660        Utf8Prefix::Disk(drive) => {
661            hash_discriminant(5, hasher);
662            hasher.write_u8(drive);
663        }
664    }
665}
666
667fn hash_discriminant<H: Hasher>(discriminant: isize, hasher: &mut H) {
668    hasher.write_isize(discriminant);
669}
670
671fn hash_str<H: Hasher>(s: &str, hasher: &mut H) {
672    hasher.write(s.as_bytes());
673    hasher.write_u8(0xff);
674}
675
676fn hash_os_str<H: Hasher>(s: &str, hasher: &mut H) {
677    hasher.write_usize(s.len());
678    hasher.write(s.as_bytes());
679}
680
681fn get_or_insert_table<'t>(parent: &'t mut Table, key: &str) -> &'t mut Table {
682    let table = parent
683        .entry(key)
684        .or_insert(Item::Table(Table::new()))
685        .as_table_mut()
686        .expect("just inserted this table");
687    table.set_implicit(true);
688    table
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694    use fixtures::json::*;
695    use guppy::graph::DependencyDirection;
696    use std::collections::{BTreeMap, btree_map::Entry};
697
698    #[test]
699    fn make_package_name_unique() {
700        for (&name, fixture) in JsonFixture::all_fixtures() {
701            let mut names_seen: BTreeMap<String, PackageMetadata<'_>> = BTreeMap::new();
702            let graph = fixture.graph();
703            for package in graph.resolve_all().packages(DependencyDirection::Forward) {
704                match names_seen.entry(make_hashed_name(&package, DepFormatVersion::V3)) {
705                    Entry::Vacant(entry) => {
706                        entry.insert(package);
707                    }
708                    Entry::Occupied(entry) => {
709                        panic!(
710                            "for fixture '{}', duplicate generated package name '{}'. packages\n\
711                        * {}\n\
712                        * {}",
713                            name,
714                            entry.key(),
715                            entry.get().id(),
716                            package.id()
717                        );
718                    }
719                }
720            }
721        }
722    }
723
724    #[test]
725    fn make_package_name_stable() {
726        let graph = JsonFixture::metadata2().graph();
727
728        let mut path_dep = None;
729        let mut external_dep = None;
730        for package in graph.resolve_all().packages(DependencyDirection::Forward) {
731            if package.name() != "walkdir" {
732                continue;
733            }
734            match package.source() {
735                // Workspace members never appear in the hakari output.
736                PackageSource::Workspace(_) => {}
737                PackageSource::Path(_) => {
738                    assert!(
739                        path_dep.replace(package).is_none(),
740                        "metadata2 has exactly one path walkdir"
741                    );
742                }
743                PackageSource::External(_) => {
744                    assert!(
745                        external_dep.replace(package).is_none(),
746                        "metadata2 has exactly one external walkdir"
747                    );
748                }
749            }
750        }
751
752        let path_dep = path_dep.expect("metadata2 has a path walkdir");
753        let external_dep = external_dep.expect("metadata2 has an external walkdir");
754
755        static EXPECTED: &[(DepFormatVersion, &str, &str)] = &[
756            (
757                DepFormatVersion::V1,
758                "walkdir-cd6c3afff8ff167c",
759                "walkdir-f595c2ba2a3f28df",
760            ),
761            (
762                DepFormatVersion::V2,
763                "walkdir-cd6c3afff8ff167c",
764                "walkdir-f595c2ba2a3f28df",
765            ),
766            (
767                DepFormatVersion::V3,
768                "walkdir-cd6c3afff8ff167c",
769                "walkdir-f595c2ba2a3f28df",
770            ),
771            (
772                DepFormatVersion::V4,
773                "walkdir-cd6c3afff8ff167c",
774                "walkdir-f595c2ba2a3f28df",
775            ),
776        ];
777
778        for &(dep_format, expected_path, expected_external) in EXPECTED {
779            assert_eq!(
780                make_hashed_name(&path_dep, dep_format),
781                expected_path,
782                "path walkdir name is stable at dep format {dep_format}; a \
783                 change here renames every workspace-hack entry for a path \
784                 dependency"
785            );
786            assert_eq!(
787                make_hashed_name(&external_dep, dep_format),
788                expected_external,
789                "external walkdir name is stable at dep format {dep_format}; \
790                 a change here renames every workspace-hack entry"
791            );
792        }
793    }
794
795    #[test]
796    fn alternate_registries() {
797        let fixture = JsonFixture::metadata_alternate_registries();
798        let mut builder =
799            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
800        builder.set_output_single_feature(true);
801        let hakari = builder.compute();
802
803        // Not plugging in the registry should generate an error.
804        let output_options = HakariOutputOptions::new();
805        hakari
806            .to_toml_string(&output_options)
807            .expect_err("no alternate registry specified => error");
808
809        let mut builder =
810            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
811        builder.set_output_single_feature(true);
812        builder.add_registries([("alt-registry", METADATA_ALTERNATE_REGISTRY_URL)]);
813        let hakari = builder.compute();
814
815        let output = hakari
816            .to_toml_string(&output_options)
817            .expect("alternate registry specified => success");
818
819        static MATCH_STRINGS: &[&str] = &[
820            // Two copies of serde, one from the main registry and one from the alt
821            r#"serde-e7e45184a9cd0878 = { package = "serde", version = "1", registry = "alt-registry", default-features = false, "#,
822            r#"serde-dff4ba8e3ae991db = { package = "serde", version = "1", default-features = false, "#,
823            // serde_derive only in the alt registry
824            r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
825            // itoa only from the main registry
826            r#"itoa = { version = "0.4", default-features = false }"#,
827        ];
828
829        for &needle in MATCH_STRINGS {
830            assert!(
831                output.contains(needle),
832                "output did not contain string '{needle}', actual output follows:\n***\n{output}\n"
833            );
834        }
835    }
836}