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::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#[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 =
413 builder.registries.get2(registry_url).ok_or_else(|| {
414 TomlOutError::UnrecognizedRegistry {
415 package_id: dep.id().clone(),
416 registry_url: registry_url.to_owned(),
417 }
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.clone().into());
432 }
433 Some(ExternalSource::Sparse(registry_url)) => {
434 let registry = builder
435 .registries
436 .get2(
437 format!("{}{}", ExternalSource::SPARSE_PLUS, registry_url)
438 .as_str(),
439 )
440 .ok_or_else(|| TomlOutError::UnrecognizedRegistry {
441 package_id: dep.id().clone(),
442 registry_url: registry_url.to_owned(),
443 })?;
444 itable.insert(
445 "version",
446 format!(
447 "{}",
448 VersionDisplay::new(
449 dep.version(),
450 options.exact_versions,
451 dep_format < DepFormatVersion::V3
452 )
453 )
454 .into(),
455 );
456 itable.insert("registry", registry.name.clone().into());
457 }
458 _ => {
459 return Err(TomlOutError::UnrecognizedExternal {
460 package_id: dep.id().clone(),
461 source: s.to_string(),
462 });
463 }
464 },
465 }
466 };
467
468 if !all_features.contains(&"default") {
469 itable.insert("default-features", false.into());
470 }
471
472 let feature_array: Array = all_features
473 .iter()
474 .filter_map(|&label| {
475 match label {
477 "default" => None,
478 feature_name => Some(feature_name),
479 }
480 })
481 .collect();
482 if !feature_array.is_empty() {
483 itable.insert("features", feature_array.into());
484 }
485
486 itable.fmt();
487
488 dep_table.insert(name.as_ref(), Item::Value(Value::InlineTable(itable)));
489 }
490
491 if dep_format >= DepFormatVersion::V4 {
492 dep_table.sort_values();
493 }
494 }
495
496 write!(out, "{}", document)?;
499 if !document.is_empty() {
500 writeln!(out)?;
501 }
502
503 Ok(())
504}
505
506fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
508 let mut hasher = XxHash64::default();
510 let minimal_version = format!(
512 "{}",
513 VersionDisplay::new(dep.version(), false, dep_format < DepFormatVersion::V3)
516 );
517 minimal_version.hash(&mut hasher);
518 dep.source().hash(&mut hasher);
519 let hash = hasher.finish();
520
521 format!("{}-{:x}", dep.name(), hash)
522}
523
524fn get_or_insert_table<'t>(parent: &'t mut Table, key: &str) -> &'t mut Table {
525 let table = parent
526 .entry(key)
527 .or_insert(Item::Table(Table::new()))
528 .as_table_mut()
529 .expect("just inserted this table");
530 table.set_implicit(true);
531 table
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use fixtures::json::*;
538 use guppy::graph::DependencyDirection;
539 use std::collections::{BTreeMap, btree_map::Entry};
540
541 #[test]
542 fn make_package_name_unique() {
543 for (&name, fixture) in JsonFixture::all_fixtures() {
544 let mut names_seen: BTreeMap<String, PackageMetadata<'_>> = BTreeMap::new();
545 let graph = fixture.graph();
546 for package in graph.resolve_all().packages(DependencyDirection::Forward) {
547 match names_seen.entry(make_hashed_name(&package, DepFormatVersion::V3)) {
548 Entry::Vacant(entry) => {
549 entry.insert(package);
550 }
551 Entry::Occupied(entry) => {
552 panic!(
553 "for fixture '{}', duplicate generated package name '{}'. packages\n\
554 * {}\n\
555 * {}",
556 name,
557 entry.key(),
558 entry.get().id(),
559 package.id()
560 );
561 }
562 }
563 }
564 }
565 }
566
567 #[test]
568 fn alternate_registries() {
569 let fixture = JsonFixture::metadata_alternate_registries();
570 let mut builder =
571 HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
572 builder.set_output_single_feature(true);
573 let hakari = builder.compute();
574
575 let output_options = HakariOutputOptions::new();
577 hakari
578 .to_toml_string(&output_options)
579 .expect_err("no alternate registry specified => error");
580
581 let mut builder =
582 HakariBuilder::new(fixture.graph(), None).expect("builder initialization succeeded");
583 builder.set_output_single_feature(true);
584 builder.add_registries([("alt-registry", METADATA_ALTERNATE_REGISTRY_URL)]);
585 let hakari = builder.compute();
586
587 let output = hakari
588 .to_toml_string(&output_options)
589 .expect("alternate registry specified => success");
590
591 static MATCH_STRINGS: &[&str] = &[
592 r#"serde-e7e45184a9cd0878 = { package = "serde", version = "1", registry = "alt-registry", default-features = false, "#,
594 r#"serde-dff4ba8e3ae991db = { package = "serde", version = "1", default-features = false, "#,
595 r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
597 r#"itoa = { version = "0.4", default-features = false }"#,
599 ];
600
601 for &needle in MATCH_STRINGS {
602 assert!(
603 output.contains(needle),
604 "output did not contain string '{}', actual output follows:\n***\n{}\n",
605 needle,
606 output
607 );
608 }
609 }
610}