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::Utf8PathBuf;
15use cfg_if::cfg_if;
16use guppy::{
17    PackageId,
18    errors::TargetSpecError,
19    graph::{ExternalSource, GitReq, PackageMetadata, PackageSource, cargo::BuildPlatform},
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 '{package_id}', no Hakari package was specified (relative path {rel_path})",
238            ),
239            TomlOutError::UnrecognizedExternal { package_id, source } => write!(
240                f,
241                "for third-party dependency '{package_id}', unrecognized external source {source}",
242            ),
243            TomlOutError::UnrecognizedRegistry {
244                package_id,
245                registry_url,
246            } => {
247                write!(
248                    f,
249                    "for third-party dependency '{package_id}', unrecognized registry at URL {registry_url}",
250                )
251            }
252        }
253    }
254}
255
256impl error::Error for TomlOutError {
257    fn source(&self) -> Option<&(dyn error::Error + 'static)> {
258        match self {
259            TomlOutError::Platform(err) => Some(err),
260            #[cfg(feature = "cli-support")]
261            TomlOutError::Toml { err, .. } => Some(err),
262            TomlOutError::FmtWrite(err) => Some(err),
263            TomlOutError::PathWithoutHakari { .. }
264            | TomlOutError::UnrecognizedExternal { .. }
265            | TomlOutError::UnrecognizedRegistry { .. } => None,
266        }
267    }
268}
269
270pub(crate) fn write_toml(
271    builder: &HakariBuilder<'_>,
272    output_map: &OutputMap<'_>,
273    options: &HakariOutputOptions,
274    dep_format: DepFormatVersion,
275    mut out: impl fmt::Write,
276) -> Result<(), TomlOutError> {
277    cfg_if! {
278        if #[cfg(feature = "cli-support")] {
279            if options.builder_summary {
280                let summary = HakariBuilderSummary::new(builder)?;
281                summary.write_comment(&mut out)?;
282                writeln!(out)?;
283            }
284        }
285    }
286
287    let mut packages_by_name: AHashMap<&str, HashSet<_>> = AHashMap::new();
288    for vals in output_map.values() {
289        for (&package_id, (package, _)) in vals {
290            packages_by_name
291                .entry(package.name())
292                .or_default()
293                .insert(package_id);
294        }
295    }
296
297    let hakari_path = builder.hakari_package().map(|package| {
298        package
299            .source()
300            .workspace_path()
301            .expect("hakari package is in workspace")
302    });
303
304    let mut document = DocumentMut::new();
305
306    // Remove the leading newline from the first visual table to match what older versions of
307    // hakari did.
308    let mut first_element = true;
309
310    for (key, vals) in output_map {
311        let dep_table_parent = match key.platform_idx {
312            Some(idx) => {
313                let target_table = get_or_insert_table(document.as_table_mut(), "target");
314                get_or_insert_table(target_table, builder.platforms[idx].triple_str())
315            }
316            None => document.as_table_mut(),
317        };
318
319        let dep_table = match key.build_platform {
320            BuildPlatform::Target => get_or_insert_table(dep_table_parent, "dependencies"),
321            BuildPlatform::Host => get_or_insert_table(dep_table_parent, "build-dependencies"),
322        };
323
324        if first_element {
325            dep_table.decor_mut().set_prefix("");
326            first_element = false;
327        }
328
329        for (dep, all_features) in vals.values() {
330            let mut itable = InlineTable::new();
331
332            let name: Cow<str> = if packages_by_name[dep.name()].len() > 1 {
333                itable.insert("package", dep.name().into());
334                make_hashed_name(dep, dep_format).into()
335            } else {
336                dep.name().into()
337            };
338
339            let source = dep.source();
340            if source.is_crates_io() {
341                itable.insert(
342                    "version",
343                    format!(
344                        "{}",
345                        VersionDisplay::new(
346                            dep.version(),
347                            options.exact_versions,
348                            dep_format < DepFormatVersion::V3
349                        )
350                    )
351                    .into(),
352                );
353            } else {
354                match source {
355                    PackageSource::Workspace(path) | PackageSource::Path(path) => {
356                        // PackageSource::Workspace shouldn't be possible unless the Hakari map
357                        // was fiddled with. Regardless, we can handle it fine.
358                        let path_out = if options.absolute_paths {
359                            // TODO: canonicalize paths here, removing .. etc? tricky if the path is
360                            // missing (as in tests)
361                            builder.graph().workspace().root().join(path)
362                        } else {
363                            let hakari_path =
364                                hakari_path.ok_or_else(|| TomlOutError::PathWithoutHakari {
365                                    package_id: dep.id().clone(),
366                                    rel_path: path.to_path_buf(),
367                                })?;
368                            pathdiff::diff_utf8_paths(path, hakari_path)
369                                .expect("both hakari_path and path are relative")
370                        }
371                        .into_string();
372
373                        cfg_if! {
374                            if #[cfg(windows)] {
375                                // TODO: is replacing \\ with / totally safe on Windows? Might run
376                                // into issues with UNC paths.
377                                let path_out = path_out.replace("\\", "/");
378                                itable.insert("path", path_out.into());
379                            } else {
380                                itable.insert("path", path_out.into());
381                            }
382                        };
383                    }
384                    PackageSource::External(s) => match source.parse_external() {
385                        Some(ExternalSource::Git {
386                            repository, req, ..
387                        }) => {
388                            itable.insert("git", repository.into());
389                            match req {
390                                GitReq::Branch(branch) => {
391                                    itable.insert("branch", branch.into());
392                                }
393                                GitReq::Tag(tag) => {
394                                    itable.insert("tag", tag.into());
395                                }
396                                GitReq::Rev(rev) => {
397                                    itable.insert("rev", rev.into());
398                                }
399                                GitReq::Default => {}
400                                _ => {
401                                    return Err(TomlOutError::UnrecognizedExternal {
402                                        package_id: dep.id().clone(),
403                                        source: s.to_string(),
404                                    });
405                                }
406                            };
407                        }
408                        Some(ExternalSource::Registry(registry_url)) => {
409                            let registry =
410                                builder.registries.get2(registry_url).ok_or_else(|| {
411                                    TomlOutError::UnrecognizedRegistry {
412                                        package_id: dep.id().clone(),
413                                        registry_url: registry_url.to_owned(),
414                                    }
415                                })?;
416                            itable.insert(
417                                "version",
418                                format!(
419                                    "{}",
420                                    VersionDisplay::new(
421                                        dep.version(),
422                                        options.exact_versions,
423                                        dep_format < DepFormatVersion::V3
424                                    )
425                                )
426                                .into(),
427                            );
428                            itable.insert("registry", registry.name.clone().into());
429                        }
430                        Some(ExternalSource::Sparse(registry_url)) => {
431                            let registry = builder
432                                .registries
433                                .get2(
434                                    format!("{}{}", ExternalSource::SPARSE_PLUS, registry_url)
435                                        .as_str(),
436                                )
437                                .ok_or_else(|| TomlOutError::UnrecognizedRegistry {
438                                    package_id: dep.id().clone(),
439                                    registry_url: registry_url.to_owned(),
440                                })?;
441                            itable.insert(
442                                "version",
443                                format!(
444                                    "{}",
445                                    VersionDisplay::new(
446                                        dep.version(),
447                                        options.exact_versions,
448                                        dep_format < DepFormatVersion::V3
449                                    )
450                                )
451                                .into(),
452                            );
453                            itable.insert("registry", registry.name.clone().into());
454                        }
455                        _ => {
456                            return Err(TomlOutError::UnrecognizedExternal {
457                                package_id: dep.id().clone(),
458                                source: s.to_string(),
459                            });
460                        }
461                    },
462                }
463            };
464
465            if !all_features.contains(&"default") {
466                itable.insert("default-features", false.into());
467            }
468
469            let feature_array: Array = all_features
470                .iter()
471                .filter_map(|&label| {
472                    // Only care about named features here.
473                    match label {
474                        "default" => None,
475                        feature_name => Some(feature_name),
476                    }
477                })
478                .collect();
479            if !feature_array.is_empty() {
480                itable.insert("features", feature_array.into());
481            }
482
483            itable.fmt();
484
485            dep_table.insert(name.as_ref(), Item::Value(Value::InlineTable(itable)));
486        }
487
488        if dep_format >= DepFormatVersion::V4 {
489            dep_table.sort_values();
490        }
491    }
492
493    // Match formatting with older versions of hakari: if the document is non-empty, print out a
494    // newline at the end.
495    write!(out, "{document}")?;
496    if !document.is_empty() {
497        writeln!(out)?;
498    }
499
500    Ok(())
501}
502
503/// Generate a unique, stable package name from the metadata.
504fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
505    // Use a fixed seed to ensure stable hashes.
506    let mut hasher = XxHash64::default();
507    // Use the minimal version so that a bump from e.g. 0.2.5 to 0.2.6 doesn't change the hash.
508    let minimal_version = format!(
509        "{}",
510        // We use a slightly different hashing scheme for V3+ formats (this is more correct but we
511        // don't want to change hashes for folks on older versions.).
512        VersionDisplay::new(dep.version(), false, dep_format < DepFormatVersion::V3)
513    );
514    minimal_version.hash(&mut hasher);
515    dep.source().hash(&mut hasher);
516    let hash = hasher.finish();
517
518    format!("{}-{:x}", dep.name(), hash)
519}
520
521fn get_or_insert_table<'t>(parent: &'t mut Table, key: &str) -> &'t mut Table {
522    let table = parent
523        .entry(key)
524        .or_insert(Item::Table(Table::new()))
525        .as_table_mut()
526        .expect("just inserted this table");
527    table.set_implicit(true);
528    table
529}
530
531#[cfg(test)]
532mod tests {
533    use super::*;
534    use fixtures::json::*;
535    use guppy::graph::DependencyDirection;
536    use std::collections::{BTreeMap, btree_map::Entry};
537
538    #[test]
539    fn make_package_name_unique() {
540        for (&name, fixture) in JsonFixture::all_fixtures() {
541            let mut names_seen: BTreeMap<String, PackageMetadata<'_>> = BTreeMap::new();
542            let graph = fixture.graph();
543            for package in graph.resolve_all().packages(DependencyDirection::Forward) {
544                match names_seen.entry(make_hashed_name(&package, DepFormatVersion::V3)) {
545                    Entry::Vacant(entry) => {
546                        entry.insert(package);
547                    }
548                    Entry::Occupied(entry) => {
549                        panic!(
550                            "for fixture '{}', duplicate generated package name '{}'. packages\n\
551                        * {}\n\
552                        * {}",
553                            name,
554                            entry.key(),
555                            entry.get().id(),
556                            package.id()
557                        );
558                    }
559                }
560            }
561        }
562    }
563
564    #[test]
565    fn alternate_registries() {
566        let fixture = JsonFixture::metadata_alternate_registries();
567        let mut builder =
568            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
569        builder.set_output_single_feature(true);
570        let hakari = builder.compute();
571
572        // Not plugging in the registry should generate an error.
573        let output_options = HakariOutputOptions::new();
574        hakari
575            .to_toml_string(&output_options)
576            .expect_err("no alternate registry specified => error");
577
578        let mut builder =
579            HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
580        builder.set_output_single_feature(true);
581        builder.add_registries([("alt-registry", METADATA_ALTERNATE_REGISTRY_URL)]);
582        let hakari = builder.compute();
583
584        let output = hakari
585            .to_toml_string(&output_options)
586            .expect("alternate registry specified => success");
587
588        static MATCH_STRINGS: &[&str] = &[
589            // Two copies of serde, one from the main registry and one from the alt
590            r#"serde-e7e45184a9cd0878 = { package = "serde", version = "1", registry = "alt-registry", default-features = false, "#,
591            r#"serde-dff4ba8e3ae991db = { package = "serde", version = "1", default-features = false, "#,
592            // serde_derive only in the alt registry
593            r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
594            // itoa only from the main registry
595            r#"itoa = { version = "0.4", default-features = false }"#,
596        ];
597
598        for &needle in MATCH_STRINGS {
599            assert!(
600                output.contains(needle),
601                "output did not contain string '{needle}', actual output follows:\n***\n{output}\n"
602            );
603        }
604    }
605}