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    hakari::{HakariBuilder, OutputMap},
10    helpers::VersionDisplay,
11    DepFormatVersion,
12};
13use ahash::AHashMap;
14use camino::Utf8PathBuf;
15use cfg_if::cfg_if;
16use guppy::{
17    errors::TargetSpecError,
18    graph::{cargo::BuildPlatform, ExternalSource, GitReq, PackageMetadata, PackageSource},
19    PackageId,
20};
21use std::{
22    borrow::Cow,
23    collections::HashSet,
24    error, fmt,
25    hash::{Hash, Hasher},
26};
27use toml_edit::{Array, DocumentMut, InlineTable, Item, Table, Value};
28use twox_hash::XxHash64;
29
30/// Options for Hakari TOML output.
31#[derive(Clone, Debug)]
32pub struct HakariOutputOptions {
33    pub(crate) exact_versions: bool,
34    pub(crate) absolute_paths: bool,
35    #[cfg(feature = "cli-support")]
36    pub(crate) builder_summary: bool,
37}
38
39impl HakariOutputOptions {
40    /// Creates a new instance with default settings.
41    ///
42    /// The default settings are:
43    /// * do not output exact versions
44    /// * do not output a summary of builder options
45    pub fn new() -> Self {
46        Self {
47            exact_versions: false,
48            absolute_paths: false,
49            #[cfg(feature = "cli-support")]
50            builder_summary: false,
51        }
52    }
53
54    /// If set to true, outputs exact versions in package version fields.
55    ///
56    /// By default, Hakari outputs the loosest possible version requirement that matches the
57    /// specified package. This is generally appropriate  if the `Cargo.lock` file isn't checked in,
58    /// and there's no automated process keeping the dependencies up-to-date.
59    ///
60    /// In some cases one may wish to output the exact versions selected instead. For example:
61    /// * The `Cargo.lock` file is checked in and all developers have matching lockfiles.
62    /// * A tool like [Dependabot](https://dependabot.com/) is configured to update `Cargo.toml`
63    ///   files to their latest versions.
64    ///
65    /// ## Note
66    ///
67    /// If set to true, and the `Cargo.lock` file isn't checked in, Hakari's output will vary based
68    /// on the repository it is run in. Most of the time this isn't desirable.
69    pub fn set_exact_versions(&mut self, exact_versions: bool) -> &mut Self {
70        self.exact_versions = exact_versions;
71        self
72    }
73
74    /// If set to true, outputs absolute paths for path dependencies.
75    ///
76    /// By default, `hakari` outputs relative paths, for example:
77    ///
78    /// ```toml
79    /// path-dependency = { path = "../../path-dependency" }
80    /// ```
81    ///
82    /// If set to true, `hakari` will output absolute paths, for example:
83    ///
84    /// ```toml
85    /// path-dependency = { path = "/path/to/path-dependency" }
86    /// ```
87    ///
88    /// In most situations, relative paths lead to better results. Use with care.
89    ///
90    /// ## Notes
91    ///
92    /// If set to false, a Hakari package must be specified in [`HakariBuilder`](HakariBuilder). If
93    /// it isn't and Hakari needs to output a relative path,
94    /// [`TomlOutError::PathWithoutHakari`](TomlOutError::PathWithoutHakari) will be returned.
95    pub fn set_absolute_paths(&mut self, absolute_paths: bool) -> &mut Self {
96        self.absolute_paths = absolute_paths;
97        self
98    }
99
100    /// If set to true, outputs a summary of the builder options used to generate the `Hakari`, as
101    /// TOML comments.
102    ///
103    /// The options are output as a header in the Hakari section:
104    ///
105    /// ```toml
106    /// # resolver = "2"
107    /// # platforms = [...]
108    /// ...
109    /// ```
110    ///
111    /// Requires the `cli-support` feature to be enabled.
112    #[cfg(feature = "cli-support")]
113    pub fn set_builder_summary(&mut self, builder_summary: bool) -> &mut Self {
114        self.builder_summary = builder_summary;
115        self
116    }
117}
118
119impl Default for HakariOutputOptions {
120    fn default() -> Self {
121        Self::new()
122    }
123}
124
125/// An error that occurred while writing out TOML.
126#[derive(Debug)]
127#[non_exhaustive]
128pub enum TomlOutError {
129    /// An error occurred while serializing platform information.
130    Platform(TargetSpecError),
131
132    /// An error occurred while serializing TOML.
133    ///
134    /// This option is only present if the `cli-support` feature is enabled.
135    #[cfg(feature = "cli-support")]
136    Toml {
137        /// A context string for the error.
138        context: Cow<'static, str>,
139
140        /// The underlying error.
141        err: toml::ser::Error,
142    },
143
144    /// An error occurred while writing to a `fmt::Write` instance.
145    FmtWrite(fmt::Error),
146
147    /// Attempted to output a path dependency, but a Hakari package wasn't provided to the builder.
148    ///
149    /// If any path dependencies need to be unified, the location of the Hakari package must be
150    /// specified so that a relative path can be displayed.
151    PathWithoutHakari {
152        /// The package ID that Hakari tried to write out a dependency line for.
153        package_id: PackageId,
154
155        /// The relative path to the package from the root of the workspace.
156        rel_path: Utf8PathBuf,
157    },
158
159    /// An external source wasn't recognized by guppy.
160    UnrecognizedExternal {
161        /// The package ID that Hakari tried to write out a dependency line for.
162        package_id: PackageId,
163
164        /// The source string that wasn't recognized.
165        source: String,
166    },
167
168    /// An external registry was found and wasn't passed into [`HakariOutputOptions`].
169    UnrecognizedRegistry {
170        /// The package ID that Hakari tried to write out a dependency line for.
171        package_id: PackageId,
172
173        /// The registry URL that wasn't recognized.
174        registry_url: String,
175    },
176}
177
178/// Returns a map from dependency names as present in the workspace `Cargo.toml` to their
179/// corresponding package metadatas.
180pub(crate) fn toml_name_map<'g>(
181    output_map: &OutputMap<'g>,
182    dep_format: DepFormatVersion,
183) -> AHashMap<Cow<'g, str>, PackageMetadata<'g>> {
184    let mut packages_by_name: AHashMap<&'g str, AHashMap<_, _>> = AHashMap::new();
185    for vals in output_map.values() {
186        for (&package_id, (package, _)) in vals {
187            packages_by_name
188                .entry(package.name())
189                .or_default()
190                .insert(package_id, package);
191        }
192    }
193
194    let mut toml_name_map = AHashMap::new();
195    for (name, packages) in packages_by_name {
196        if packages.len() > 1 {
197            // Make hashed names for each package.
198            for (_, package) in packages {
199                let hashed_name = make_hashed_name(package, dep_format);
200                toml_name_map.insert(Cow::Owned(hashed_name), *package);
201            }
202        } else {
203            toml_name_map.insert(
204                Cow::Borrowed(name),
205                *packages.into_values().next().expect("at least 1 element"),
206            );
207        }
208    }
209
210    toml_name_map
211}
212
213impl From<TargetSpecError> for TomlOutError {
214    fn from(err: TargetSpecError) -> Self {
215        TomlOutError::Platform(err)
216    }
217}
218
219impl From<fmt::Error> for TomlOutError {
220    fn from(err: fmt::Error) -> Self {
221        TomlOutError::FmtWrite(err)
222    }
223}
224
225impl fmt::Display for TomlOutError {
226    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
227        match self {
228            TomlOutError::Platform(_) => write!(f, "while serializing platform information"),
229            #[cfg(feature = "cli-support")]
230            TomlOutError::Toml { context, .. } => write!(f, "while serializing TOML: {}", context),
231            TomlOutError::FmtWrite(_) => write!(f, "while writing to fmt::Write"),
232            TomlOutError::PathWithoutHakari {
233                package_id,
234                rel_path,
235            } => write!(
236                f,
237                "for path dependency '{}', no Hakari package was specified (relative path {})",
238                package_id, rel_path,
239            ),
240            TomlOutError::UnrecognizedExternal { package_id, source } => write!(
241                f,
242                "for third-party dependency '{}', unrecognized external source {}",
243                package_id, source,
244            ),
245            TomlOutError::UnrecognizedRegistry {
246                package_id,
247                registry_url,
248            } => {
249                write!(
250                    f,
251                    "for third-party dependency '{}', unrecognized registry at URL {}",
252                    package_id, registry_url,
253                )
254            }
255        }
256    }
257}
258
259impl error::Error for TomlOutError {
260    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
261        match self {
262            TomlOutError::Platform(err) => Some(err),
263            #[cfg(feature = "cli-support")]
264            TomlOutError::Toml { err, .. } => Some(err),
265            TomlOutError::FmtWrite(err) => Some(err),
266            TomlOutError::PathWithoutHakari { .. }
267            | TomlOutError::UnrecognizedExternal { .. }
268            | TomlOutError::UnrecognizedRegistry { .. } => None,
269        }
270    }
271}
272
273pub(crate) fn write_toml(
274    builder: &HakariBuilder<'_>,
275    output_map: &OutputMap<'_>,
276    options: &HakariOutputOptions,
277    dep_format: DepFormatVersion,
278    mut out: impl fmt::Write,
279) -> Result<(), TomlOutError> {
280    cfg_if! {
281        if #[cfg(feature = "cli-support")] {
282            if options.builder_summary {
283                let summary = HakariBuilderSummary::new(builder)?;
284                summary.write_comment(&mut out)?;
285                writeln!(out)?;
286            }
287        }
288    }
289
290    let mut packages_by_name: AHashMap<&str, HashSet<_>> = AHashMap::new();
291    for vals in output_map.values() {
292        for (&package_id, (package, _)) in vals {
293            packages_by_name
294                .entry(package.name())
295                .or_default()
296                .insert(package_id);
297        }
298    }
299
300    let hakari_path = builder.hakari_package().map(|package| {
301        package
302            .source()
303            .workspace_path()
304            .expect("hakari package is in workspace")
305    });
306
307    let mut document = DocumentMut::new();
308
309    // Remove the leading newline from the first visual table to match what older versions of
310    // hakari did.
311    let mut first_element = true;
312
313    for (key, vals) in output_map {
314        let dep_table_parent = match key.platform_idx {
315            Some(idx) => {
316                let target_table = get_or_insert_table(document.as_table_mut(), "target");
317                get_or_insert_table(target_table, builder.platforms[idx].triple_str())
318            }
319            None => document.as_table_mut(),
320        };
321
322        let dep_table = match key.build_platform {
323            BuildPlatform::Target => get_or_insert_table(dep_table_parent, "dependencies"),
324            BuildPlatform::Host => get_or_insert_table(dep_table_parent, "build-dependencies"),
325        };
326
327        if first_element {
328            dep_table.decor_mut().set_prefix("");
329            first_element = false;
330        }
331
332        for (dep, all_features) in vals.values() {
333            let mut itable = InlineTable::new();
334
335            let name: Cow<str> = if packages_by_name[dep.name()].len() > 1 {
336                itable.insert("package", dep.name().into());
337                make_hashed_name(dep, dep_format).into()
338            } else {
339                dep.name().into()
340            };
341
342            let source = dep.source();
343            if source.is_crates_io() {
344                itable.insert(
345                    "version",
346                    format!(
347                        "{}",
348                        VersionDisplay::new(
349                            dep.version(),
350                            options.exact_versions,
351                            dep_format < DepFormatVersion::V3
352                        )
353                    )
354                    .into(),
355                );
356            } else {
357                match source {
358                    PackageSource::Workspace(path) | PackageSource::Path(path) => {
359                        // PackageSource::Workspace shouldn't be possible unless the Hakari map
360                        // was fiddled with. Regardless, we can handle it fine.
361                        let path_out = if options.absolute_paths {
362                            // TODO: canonicalize paths here, removing .. etc? tricky if the path is
363                            // missing (as in tests)
364                            builder.graph().workspace().root().join(path)
365                        } else {
366                            let hakari_path =
367                                hakari_path.ok_or_else(|| TomlOutError::PathWithoutHakari {
368                                    package_id: dep.id().clone(),
369                                    rel_path: path.to_path_buf(),
370                                })?;
371                            pathdiff::diff_utf8_paths(path, hakari_path)
372                                .expect("both hakari_path and path are relative")
373                        }
374                        .into_string();
375
376                        cfg_if! {
377                            if #[cfg(windows)] {
378                                // TODO: is replacing \\ with / totally safe on Windows? Might run
379                                // into issues with UNC paths.
380                                let path_out = path_out.replace("\\", "/");
381                                itable.insert("path", path_out.into());
382                            } else {
383                                itable.insert("path", path_out.into());
384                            }
385                        };
386                    }
387                    PackageSource::External(s) => match source.parse_external() {
388                        Some(ExternalSource::Git {
389                            repository, req, ..
390                        }) => {
391                            itable.insert("git", repository.into());
392                            match req {
393                                GitReq::Branch(branch) => {
394                                    itable.insert("branch", branch.into());
395                                }
396                                GitReq::Tag(tag) => {
397                                    itable.insert("tag", tag.into());
398                                }
399                                GitReq::Rev(rev) => {
400                                    itable.insert("rev", rev.into());
401                                }
402                                GitReq::Default => {}
403                                _ => {
404                                    return Err(TomlOutError::UnrecognizedExternal {
405                                        package_id: dep.id().clone(),
406                                        source: s.to_string(),
407                                    });
408                                }
409                            };
410                        }
411                        Some(ExternalSource::Registry(registry_url)) => {
412                            let registry_name = builder
413                                .registries
414                                .get_by_right(registry_url)
415                                .ok_or_else(|| TomlOutError::UnrecognizedRegistry {
416                                    package_id: dep.id().clone(),
417                                    registry_url: registry_url.to_owned(),
418                                })?;
419                            itable.insert(
420                                "version",
421                                format!(
422                                    "{}",
423                                    VersionDisplay::new(
424                                        dep.version(),
425                                        options.exact_versions,
426                                        dep_format < DepFormatVersion::V3
427                                    )
428                                )
429                                .into(),
430                            );
431                            itable.insert("registry", registry_name.into());
432                        }
433                        Some(ExternalSource::Sparse(registry_url)) => {
434                            let registry_name = builder
435                                .registries
436                                .get_by_right(&format!(
437                                    "{}{}",
438                                    ExternalSource::SPARSE_PLUS,
439                                    registry_url
440                                ))
441                                .ok_or_else(|| TomlOutError::UnrecognizedRegistry {
442                                    package_id: dep.id().clone(),
443                                    registry_url: registry_url.to_owned(),
444                                })?;
445                            itable.insert(
446                                "version",
447                                format!(
448                                    "{}",
449                                    VersionDisplay::new(
450                                        dep.version(),
451                                        options.exact_versions,
452                                        dep_format < DepFormatVersion::V3
453                                    )
454                                )
455                                .into(),
456                            );
457                            itable.insert("registry", registry_name.into());
458                        }
459                        _ => {
460                            return Err(TomlOutError::UnrecognizedExternal {
461                                package_id: dep.id().clone(),
462                                source: s.to_string(),
463                            });
464                        }
465                    },
466                }
467            };
468
469            if !all_features.contains(&"default") {
470                itable.insert("default-features", false.into());
471            }
472
473            let feature_array: Array = all_features
474                .iter()
475                .filter_map(|&label| {
476                    // Only care about named features here.
477                    match label {
478                        "default" => None,
479                        feature_name => Some(feature_name),
480                    }
481                })
482                .collect();
483            if !feature_array.is_empty() {
484                itable.insert("features", feature_array.into());
485            }
486
487            itable.fmt();
488
489            dep_table.insert(name.as_ref(), Item::Value(Value::InlineTable(itable)));
490        }
491
492        if dep_format >= DepFormatVersion::V4 {
493            dep_table.sort_values();
494        }
495    }
496
497    // Match formatting with older versions of hakari: if the document is non-empty, print out a
498    // newline at the end.
499    write!(out, "{}", document)?;
500    if !document.is_empty() {
501        writeln!(out)?;
502    }
503
504    Ok(())
505}
506
507/// Generate a unique, stable package name from the metadata.
508fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
509    // Use a fixed seed to ensure stable hashes.
510    let mut hasher = XxHash64::default();
511    // Use the minimal version so that a bump from e.g. 0.2.5 to 0.2.6 doesn't change the hash.
512    let minimal_version = format!(
513        "{}",
514        // We use a slightly different hashing scheme for V3+ formats (this is more correct but we
515        // don't want to change hashes for folks on older versions.).
516        VersionDisplay::new(dep.version(), false, dep_format < DepFormatVersion::V3)
517    );
518    minimal_version.hash(&mut hasher);
519    dep.source().hash(&mut hasher);
520    let hash = hasher.finish();
521
522    format!("{}-{:x}", dep.name(), hash)
523}
524
525fn get_or_insert_table<'t>(parent: &'t mut Table, key: &str) -> &'t mut Table {
526    let table = parent
527        .entry(key)
528        .or_insert(Item::Table(Table::new()))
529        .as_table_mut()
530        .expect("just inserted this table");
531    table.set_implicit(true);
532    table
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538    use fixtures::json::*;
539    use guppy::graph::DependencyDirection;
540    use std::collections::{btree_map::Entry, BTreeMap};
541
542    #[test]
543    fn make_package_name_unique() {
544        for (&name, fixture) in JsonFixture::all_fixtures() {
545            let mut names_seen: BTreeMap<String, PackageMetadata<'_>> = BTreeMap::new();
546            let graph = fixture.graph();
547            for package in graph.resolve_all().packages(DependencyDirection::Forward) {
548                match names_seen.entry(make_hashed_name(&package, DepFormatVersion::V3)) {
549                    Entry::Vacant(entry) => {
550                        entry.insert(package);
551                    }
552                    Entry::Occupied(entry) => {
553                        panic!(
554                            "for fixture '{}', duplicate generated package name '{}'. packages\n\
555                        * {}\n\
556                        * {}",
557                            name,
558                            entry.key(),
559                            entry.get().id(),
560                            package.id()
561                        );
562                    }
563                }
564            }
565        }
566    }
567
568    #[test]
569    fn alternate_registries() {
570        let fixture = JsonFixture::metadata_alternate_registries();
571        let mut builder =
572            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
573        builder.set_output_single_feature(true);
574        let hakari = builder.compute();
575
576        // Not plugging in the registry should generate an error.
577        let output_options = HakariOutputOptions::new();
578        hakari
579            .to_toml_string(&output_options)
580            .expect_err("no alternate registry specified => error");
581
582        let mut builder =
583            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
584        builder.set_output_single_feature(true);
585        builder.add_registries([("alt-registry", METADATA_ALTERNATE_REGISTRY_URL)]);
586        let hakari = builder.compute();
587
588        let output = hakari
589            .to_toml_string(&output_options)
590            .expect("alternate registry specified => success");
591
592        static MATCH_STRINGS: &[&str] = &[
593            // Two copies of serde, one from the main registry and one from the alt
594            r#"serde-e7e45184a9cd0878 = { package = "serde", version = "1", registry = "alt-registry", default-features = false, "#,
595            r#"serde-dff4ba8e3ae991db = { package = "serde", version = "1", default-features = false, "#,
596            // serde_derive only in the alt registry
597            r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
598            // itoa only from the main registry
599            r#"itoa = { version = "0.4", default-features = false }"#,
600        ];
601
602        for &needle in MATCH_STRINGS {
603            assert!(
604                output.contains(needle),
605                "output did not contain string '{}', actual output follows:\n***\n{}\n",
606                needle,
607                output
608            );
609        }
610    }
611}