1#[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#[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 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 pub fn set_exact_versions(&mut self, exact_versions: bool) -> &mut Self {
71 self.exact_versions = exact_versions;
72 self
73 }
74
75 pub fn set_absolute_paths(&mut self, absolute_paths: bool) -> &mut Self {
97 self.absolute_paths = absolute_paths;
98 self
99 }
100
101 #[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#[derive(Debug)]
128#[non_exhaustive]
129pub enum TomlOutError {
130 Platform(TargetSpecError),
132
133 #[cfg(feature = "cli-support")]
137 Toml {
138 context: Cow<'static, str>,
140
141 err: toml::ser::Error,
143 },
144
145 FmtWrite(fmt::Error),
147
148 PathWithoutHakari {
153 package_id: PackageId,
155
156 rel_path: Utf8PathBuf,
158 },
159
160 UnrecognizedExternal {
162 package_id: PackageId,
164
165 source: String,
167 },
168
169 UnrecognizedRegistry {
171 package_id: PackageId,
173
174 registry_url: String,
176 },
177}
178
179#[derive(Clone, Debug, Hash, PartialEq, Eq, PartialOrd, Ord)]
182pub enum TomlName<'g> {
183 Plain(&'g str),
186
187 Hashed(Box<str>),
190}
191
192impl TomlName<'_> {
193 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#[derive(Clone, Debug)]
211pub struct TomlNameEntry<'g> {
212 toml_name: TomlName<'g>,
213 package: PackageMetadata<'g>,
214}
215
216impl<'g> TomlNameEntry<'g> {
217 pub fn toml_name(&self) -> &TomlName<'g> {
219 &self.toml_name
220 }
221
222 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
241pub(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 let mut toml_name_map = IdOrdMap::new();
262 for (name, packages) in packages_by_name {
263 if packages.len() > 1 {
264 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 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 let path_out = if options.absolute_paths {
433 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 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 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 write!(out, "{document}")?;
570 if !document.is_empty() {
571 writeln!(out)?;
572 }
573
574 Ok(())
575}
576
577fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
579 let mut hasher = XxHash64::default();
581 let minimal_version = format!(
583 "{}",
584 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
595fn 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 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 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 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 r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
825 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}