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 '{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 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 let path_out = if options.absolute_paths {
359 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 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 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 write!(out, "{document}")?;
496 if !document.is_empty() {
497 writeln!(out)?;
498 }
499
500 Ok(())
501}
502
503fn make_hashed_name(dep: &PackageMetadata<'_>, dep_format: DepFormatVersion) -> String {
505 let mut hasher = XxHash64::default();
507 let minimal_version = format!(
509 "{}",
510 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 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 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 r#"serde_derive = { version = "1", registry = "alt-registry" }"#,
594 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}