1#[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#[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 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 pub fn set_exact_versions(&mut self, exact_versions: bool) -> &mut Self {
70 self.exact_versions = exact_versions;
71 self
72 }
73
74 pub fn set_absolute_paths(&mut self, absolute_paths: bool) -> &mut Self {
96 self.absolute_paths = absolute_paths;
97 self
98 }
99
100 #[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#[derive(Debug)]
127#[non_exhaustive]
128pub enum TomlOutError {
129 Platform(TargetSpecError),
131
132 #[cfg(feature = "cli-support")]
136 Toml {
137 context: Cow<'static, str>,
139
140 err: toml::ser::Error,
142 },
143
144 FmtWrite(fmt::Error),
146
147 PathWithoutHakari {
152 package_id: PackageId,
154
155 rel_path: Utf8PathBuf,
157 },
158
159 UnrecognizedExternal {
161 package_id: PackageId,
163
164 source: String,
166 },
167
168 UnrecognizedRegistry {
170 package_id: PackageId,
172
173 registry_url: String,
175 },
176}
177
178pub(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 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 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 let path_out = if options.absolute_paths {
362 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 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 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 write!(out, "{}", document)?;
500 if !document.is_empty() {
501 writeln!(out)?;
502 }
503
504 Ok(())
505}
506
507fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
509 let mut hasher = XxHash64::default();
511 let minimal_version = format!(
513 "{}",
514 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 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 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 r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
598 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}