cargo_docs_md/multi_crate/registry.rs
1//! Unified link registry for cross-crate documentation.
2//!
3//! This module provides [`UnifiedLinkRegistry`] which maps item IDs across
4//! multiple crates to their documentation file paths, enabling cross-crate
5//! linking in the generated markdown.
6//!
7//! # `rustdoc_types` Path Types
8//!
9//! There are two distinct path representations in `rustdoc_types`:
10//!
11//! - **[`ItemSummary`]**: Contains metadata about items without full content.
12//! - `path: Vec<String>` - Structured path segments like `["std", "vec", "Vec"]`
13//! - `kind: ItemKind` - The item's kind (Struct, Enum, Trait, etc.)
14//! - Use for: kind filtering, path lookups, metadata queries
15//!
16//! - **[`Item`]**: Full item content including inner details.
17//! - `inner: ItemEnum` - The actual item content (Struct/Enum/Trait data)
18//! - Use for: rendering, accessing item members, documentation content
19//!
20//! **Optimization tip**: When only the item kind is needed, prefer
21//! `krate.paths.get(&id).map(|p| p.kind)` over looking up the full `Item`.
22//!
23//! [`ItemSummary`]: rustdoc_types::ItemSummary
24//! [`Item`]: rustdoc_types::Item
25
26use std::collections::HashMap;
27use std::hash::{Hash, Hasher};
28
29use compact_str::CompactString;
30use rustdoc_types::{Crate, Id, ItemEnum, ItemKind, Visibility};
31use tracing::instrument;
32
33use super::{CrateCollection, RUST_PATH_SEP};
34use crate::linker::{AnchorUtils, LinkRegistry};
35
36/// Compact string type for memory-efficient storage.
37/// Strings ≤24 bytes are stored inline (no heap allocation).
38/// Most crate names, item names, and short paths fit inline.
39type Str = CompactString;
40
41/// Key type for registry lookups: `(crate_name, item_id)`.
42///
43/// Uses `CompactString` for memory efficiency - most crate names are short
44/// and stored inline without heap allocation.
45type RegistryKey = (Str, Id);
46
47/// Borrowed key for zero-allocation lookups.
48///
49/// Must hash identically to `RegistryKey` tuple of `(CompactString, Id)`.
50#[derive(PartialEq, Eq)]
51struct BorrowedKey<'a>(&'a str, Id);
52
53impl Hash for BorrowedKey<'_> {
54 fn hash<H: Hasher>(&self, state: &mut H) {
55 // Hash exactly like a tuple (CompactString, Id) would:
56 // CompactString hashes as its byte content, same as &str
57 self.0.hash(state);
58 self.1.hash(state);
59 }
60}
61
62/// Allow comparing `BorrowedKey` with `RegistryKey`.
63fn keys_match(stored: &RegistryKey, borrowed: &BorrowedKey<'_>) -> bool {
64 stored.0 == borrowed.0 && stored.1 == borrowed.1
65}
66
67/// Registry mapping item IDs to documentation paths across multiple crates.
68///
69/// Unlike [`LinkRegistry`] which handles a single crate, this registry
70/// spans multiple crates and supports cross-crate link resolution with
71/// disambiguation based on local/primary crate preference.
72///
73/// # Path Format
74///
75/// All paths use the nested format: `{crate_name}/{module_path}/index.md`
76///
77/// Examples:
78/// - `tracing/index.md` (crate root)
79/// - `tracing/span/index.md` (module)
80/// - `tracing_core/subscriber/index.md` (cross-crate reference)
81///
82/// # Link Resolution Priority
83///
84/// When resolving ambiguous names:
85/// 1. Items in the current crate (where the link appears)
86/// 2. Items in the primary crate (if specified via `--primary-crate`)
87/// 3. Items with the shortest qualified path
88///
89/// # Performance
90///
91/// Uses `hashbrown` with raw entry API for zero-allocation lookups.
92/// This avoids allocating a `String` for the crate name on every lookup.
93#[derive(Debug, Default)]
94pub struct UnifiedLinkRegistry {
95 /// Maps `(crate_name, item_id)` to the file path within output.
96 /// Uses hashbrown for `raw_entry` API (zero-alloc lookups).
97 item_paths: hashbrown::HashMap<RegistryKey, Str>,
98
99 /// Maps `(crate_name, item_id)` to the item's display name.
100 /// Uses hashbrown for `raw_entry` API (zero-alloc lookups).
101 item_names: hashbrown::HashMap<RegistryKey, Str>,
102
103 /// Maps short names to all `(crate_name, item_id, item_kind)` tuples.
104 /// Used for disambiguating links like `Span` that exist in multiple crates.
105 /// The `ItemKind` enables preferring modules over macros with the same name.
106 name_index: HashMap<Str, Vec<(Str, Id, ItemKind)>>,
107
108 /// Maps `(crate_name, reexport_id)` to the original source path.
109 /// Used for resolving external re-exports where `use_item.id` is `None`
110 /// but `use_item.source` provides the canonical path.
111 /// Example: `("tracing", id_123)` -> `"tracing_core::field::Visit"`
112 re_export_sources: hashbrown::HashMap<RegistryKey, Str>,
113
114 /// The primary crate name for preferential resolution.
115 primary_crate: Option<Str>,
116}
117
118impl UnifiedLinkRegistry {
119 /// Build a unified registry from a collection of crates.
120 ///
121 /// # Arguments
122 ///
123 /// * `crates` - Collection of parsed crates
124 /// * `primary_crate` - Optional primary crate for disambiguation
125 ///
126 /// # Returns
127 ///
128 /// A populated registry ready for link resolution.
129 #[must_use]
130 #[instrument(skip(crates), fields(crate_count = crates.names().len()))]
131 pub fn build(crates: &CrateCollection, primary_crate: Option<&str>) -> Self {
132 #[cfg(feature = "trace")]
133 tracing::debug!(?primary_crate, "Building unified link registry");
134
135 let mut registry = Self {
136 primary_crate: primary_crate.map(Str::from),
137 ..Default::default()
138 };
139
140 // Register all items from each crate
141 for (crate_name, krate) in crates.iter() {
142 #[cfg(feature = "trace")]
143 tracing::trace!(crate_name, "Registering crate items");
144
145 registry.register_crate(crate_name, krate);
146 }
147
148 #[cfg(feature = "trace")]
149 tracing::debug!(
150 item_count = registry.item_paths.len(),
151 name_count = registry.name_index.len(),
152 "Registry build complete"
153 );
154
155 registry
156 }
157
158 /// Register all items from a single crate.
159 fn register_crate(&mut self, crate_name: &str, krate: &Crate) {
160 // Get root module
161 let Some(root) = krate.index.get(&krate.root) else {
162 return;
163 };
164
165 // Register root module at index.md (no crate prefix in path)
166 self.register_item(
167 crate_name,
168 krate.root,
169 crate_name,
170 "index.md",
171 ItemKind::Module,
172 );
173
174 // Strategy 1: Use the `paths` field to register all items by their canonical path
175 // This catches items that are re-exported or in private modules
176 self.register_from_paths(crate_name, krate);
177
178 // Strategy 2: Process all items in root module recursively
179 // This ensures we have correct paths for the generated markdown structure
180 if let ItemEnum::Module(module) = &root.inner {
181 for item_id in &module.items {
182 if let Some(item) = krate.index.get(item_id) {
183 self.register_item_recursive(krate, crate_name, *item_id, item, "");
184 }
185 }
186 }
187 }
188
189 /// Register items using the `paths` field from rustdoc JSON.
190 ///
191 /// The `paths` field contains canonical paths for all items, including
192 /// those in private modules that are re-exported publicly. Since we only
193 /// generate docs for public modules, items in private modules are
194 /// documented at their public re-export location (typically root).
195 fn register_from_paths(&mut self, crate_name: &str, krate: &Crate) {
196 for (id, path_info) in &krate.paths {
197 // Only register items from this crate
198 if path_info.crate_id != 0 {
199 continue;
200 }
201
202 // Get the item name (last segment of path)
203 let Some(name) = path_info.path.last() else {
204 continue;
205 };
206
207 // Skip modules - they're handled by recursive traversal
208 if path_info.kind == rustdoc_types::ItemKind::Module {
209 continue;
210 }
211
212 // Items from paths are typically in private modules that get re-exported
213 // at the crate root. Register them at index.md since that's where
214 // public re-exports are documented.
215 // The recursive traversal will overwrite with correct paths for items
216 // that ARE in public modules.
217 self.register_item(crate_name, *id, name, "index.md", path_info.kind);
218 }
219 }
220
221 /// Convert `ItemEnum` to `ItemKind` for the name index.
222 #[expect(clippy::match_same_arms)]
223 const fn item_enum_to_kind(inner: &ItemEnum) -> ItemKind {
224 match inner {
225 ItemEnum::Module(_) => ItemKind::Module,
226
227 ItemEnum::Struct(_) => ItemKind::Struct,
228
229 ItemEnum::Enum(_) => ItemKind::Enum,
230
231 ItemEnum::Trait(_) => ItemKind::Trait,
232
233 ItemEnum::Function(_) => ItemKind::Function,
234
235 ItemEnum::Constant { .. } => ItemKind::Constant,
236
237 ItemEnum::TypeAlias(_) => ItemKind::TypeAlias,
238
239 ItemEnum::Macro(_) => ItemKind::Macro,
240
241 ItemEnum::Use(_) => ItemKind::Use,
242
243 _ => ItemKind::Use, // Fallback for other types
244 }
245 }
246
247 /// Recursively register an item and its children.
248 fn register_item_recursive(
249 &mut self,
250 krate: &Crate,
251 crate_name: &str,
252 item_id: Id,
253 item: &rustdoc_types::Item,
254 parent_path: &str,
255 ) {
256 let name = item.name.as_deref().unwrap_or("unnamed");
257
258 match &item.inner {
259 // Modules get their own directory with index.md
260 ItemEnum::Module(module) => {
261 // Build module path (handle empty parent for root-level modules)
262 let module_path = if parent_path.is_empty() {
263 name.to_string()
264 } else {
265 format!("{parent_path}/{name}")
266 };
267 let file_path = format!("{module_path}/index.md");
268
269 self.register_item(crate_name, item_id, name, &file_path, ItemKind::Module);
270
271 // Recurse into child items
272 for child_id in &module.items {
273 if let Some(child) = krate.index.get(child_id) {
274 self.register_item_recursive(
275 krate,
276 crate_name,
277 *child_id,
278 child,
279 &module_path,
280 );
281 }
282 }
283 },
284
285 // Types and functions are documented in their parent's index.md
286 ItemEnum::Struct(_)
287 | ItemEnum::Enum(_)
288 | ItemEnum::Trait(_)
289 | ItemEnum::Function(_)
290 | ItemEnum::Constant { .. }
291 | ItemEnum::TypeAlias(_)
292 | ItemEnum::Macro(_) => {
293 // Handle root-level items (parent_path is empty)
294 let file_path = if parent_path.is_empty() {
295 "index.md".to_string()
296 } else {
297 format!("{parent_path}/index.md")
298 };
299 let kind = Self::item_enum_to_kind(&item.inner);
300 self.register_item(crate_name, item_id, name, &file_path, kind);
301 },
302
303 // Re-exports (pub use) should be registered under this crate's namespace
304 // This allows links to resolve within the current crate rather than cross-crate
305 ItemEnum::Use(use_item) => {
306 let file_path = if parent_path.is_empty() {
307 "index.md".to_string()
308 } else {
309 format!("{parent_path}/index.md")
310 };
311
312 if use_item.is_glob {
313 // Register items from glob re-export target
314 if let Some(target_id) = &use_item.id
315 && let Some(target_module) = krate.index.get(target_id)
316 && let ItemEnum::Module(module) = &target_module.inner
317 {
318 for child_id in &module.items {
319 if let Some(child) = krate.index.get(child_id) {
320 // Check visibility
321 if !matches!(child.visibility, Visibility::Public) {
322 continue;
323 }
324
325 let child_name = child.name.as_deref().unwrap_or("unnamed");
326 let child_kind = Self::item_enum_to_kind(&child.inner);
327
328 self.register_item(
329 crate_name, *child_id, child_name, &file_path, child_kind,
330 );
331 }
332 }
333 }
334 } else {
335 // Specific re-export - try to get kind from target, fallback to Use
336 let export_name = &use_item.name;
337 let kind = use_item
338 .id
339 .and_then(|id| krate.index.get(&id))
340 .map_or(ItemKind::Use, |target| {
341 Self::item_enum_to_kind(&target.inner)
342 });
343
344 self.register_item(crate_name, item_id, export_name, &file_path, kind);
345
346 // Also register the TARGET item's ID to this path, but ONLY if it's not
347 // already registered. This ensures links to items defined in submodules
348 // (and re-exported from parent modules) resolve to the original definition
349 // location when generating docs for that submodule, rather than the
350 // re-export location. Without this check, `TocEntry` defined in `toc/`
351 // and re-exported from `generator/` would always link to `generator/index.md`
352 // even when we're generating `toc/index.md` (where it should be `#tocentry`).
353 if let Some(target_id) = use_item.id
354 && !self.contains(crate_name, target_id)
355 {
356 self.register_item(crate_name, target_id, export_name, &file_path, kind);
357 }
358
359 // For ALL re-exports, store the source path so we can
360 // resolve to the original definition (which has a heading)
361 if !use_item.source.is_empty() {
362 let key = (Str::from(crate_name), item_id);
363 self.re_export_sources
364 .insert(key, Str::from(use_item.source.as_str()));
365 }
366 }
367 },
368
369 _ => {},
370 }
371 }
372
373 /// Register a single item in the registry.
374 fn register_item(&mut self, crate_name: &str, id: Id, name: &str, path: &str, kind: ItemKind) {
375 let key = (Str::from(crate_name), id);
376
377 self.item_paths.insert(key.clone(), Str::from(path));
378 self.item_names.insert(key, Str::from(name));
379
380 // Add to name index for disambiguation (includes kind for preference logic)
381 self.name_index
382 .entry(Str::from(name))
383 .or_default()
384 .push((Str::from(crate_name), id, kind));
385 }
386
387 /// Get the file path for an item in a specific crate.
388 ///
389 /// Uses raw entry API for zero-allocation lookup.
390 #[must_use]
391 #[instrument(skip(self), level = "trace")]
392 pub fn get_path(&self, crate_name: &str, id: Id) -> Option<&Str> {
393 use std::hash::BuildHasher;
394 let borrowed = BorrowedKey(crate_name, id);
395 let hash = self.item_paths.hasher().hash_one(&borrowed);
396 let result = self
397 .item_paths
398 .raw_entry()
399 .from_hash(hash, |k| keys_match(k, &borrowed))
400 .map(|(_, v)| v);
401
402 #[cfg(feature = "trace")]
403 tracing::trace!(found = result.is_some(), "Path lookup");
404
405 result
406 }
407
408 /// Get the display name for an item.
409 ///
410 /// Uses raw entry API for zero-allocation lookup.
411 #[must_use]
412 pub fn get_name(&self, crate_name: &str, id: Id) -> Option<&Str> {
413 use std::hash::BuildHasher;
414 let borrowed = BorrowedKey(crate_name, id);
415 let hash = self.item_names.hasher().hash_one(&borrowed);
416
417 self.item_names
418 .raw_entry()
419 .from_hash(hash, |k| keys_match(k, &borrowed))
420 .map(|(_, v)| v)
421 }
422
423 /// Get the original source path for an external re-export.
424 ///
425 /// Returns `Some("crate::path::Item")` if this item is a re-export
426 /// from another crate, `None` otherwise.
427 #[must_use]
428 pub fn get_re_export_source(&self, crate_name: &str, id: Id) -> Option<&Str> {
429 use std::hash::BuildHasher;
430 let borrowed = BorrowedKey(crate_name, id);
431 let hash = self.re_export_sources.hasher().hash_one(&borrowed);
432
433 self.re_export_sources
434 .raw_entry()
435 .from_hash(hash, |k| keys_match(k, &borrowed))
436 .map(|(_, v)| v)
437 }
438
439 /// Resolve through re-export chain to find the canonical item.
440 ///
441 /// If the item is an external re-export, follows the source path
442 /// to find the original crate and ID. Returns the original if found,
443 /// otherwise returns `None`.
444 ///
445 /// # Arguments
446 ///
447 /// * `crate_name` - The crate where the re-export appears
448 /// * `id` - The ID of the re-export Use item
449 ///
450 /// # Returns
451 ///
452 /// `Some((original_crate, original_id))` if the re-export chain can be resolved,
453 /// `None` if there's no re-export source or the original can't be found.
454 #[must_use]
455 pub fn resolve_reexport(&self, crate_name: &str, id: Id) -> Option<(Str, Id)> {
456 let source = self.get_re_export_source(crate_name, id)?;
457
458 self.resolve_path(source)
459 }
460
461 /// Resolve an item name to its crate and ID.
462 ///
463 /// Uses disambiguation priority:
464 /// 1. Current crate (modules preferred over macros)
465 /// 2. Primary crate (if set, modules preferred)
466 /// 3. First module match, then first non-module match
467 #[must_use]
468 #[instrument(skip(self), level = "trace")]
469 pub fn resolve_name(&self, name: &str, current_crate: &str) -> Option<(Str, Id)> {
470 let candidates = self.name_index.get(name)?;
471
472 if candidates.is_empty() {
473 #[cfg(feature = "trace")]
474 tracing::trace!("No candidates found");
475
476 return None;
477 }
478
479 // Priority 1: Current crate - prefer modules over macros
480 let current_crate_candidates: Vec<_> = candidates
481 .iter()
482 .filter(|(crate_name, _, _)| crate_name == current_crate)
483 .collect();
484
485 if !current_crate_candidates.is_empty() {
486 // Prefer module if available
487 if let Some((crate_name, id, _)) = current_crate_candidates
488 .iter()
489 .find(|(_, _, kind)| *kind == ItemKind::Module)
490 {
491 #[cfg(feature = "trace")]
492 tracing::trace!(resolved_crate = %crate_name, "Resolved to current crate (module)");
493
494 return Some(((*crate_name).clone(), *id));
495 }
496
497 // Otherwise take first match from current crate
498 let (crate_name, id, _) = current_crate_candidates[0];
499
500 #[cfg(feature = "trace")]
501 tracing::trace!(resolved_crate = %crate_name, "Resolved to current crate");
502
503 return Some((crate_name.clone(), *id));
504 }
505
506 // Priority 2: Primary crate - prefer modules
507 if let Some(primary) = &self.primary_crate {
508 let primary_candidates: Vec<_> = candidates
509 .iter()
510 .filter(|(crate_name, _, _)| crate_name == primary)
511 .collect();
512
513 if !primary_candidates.is_empty() {
514 // Prefer module if available
515 if let Some((crate_name, id, _)) = primary_candidates
516 .iter()
517 .find(|(_, _, kind)| *kind == ItemKind::Module)
518 {
519 #[cfg(feature = "trace")]
520 tracing::trace!(resolved_crate = %crate_name, "Resolved to primary crate (module)");
521
522 return Some(((*crate_name).clone(), *id));
523 }
524
525 // Otherwise take first match from primary crate
526 let (crate_name, id, _) = primary_candidates[0];
527
528 #[cfg(feature = "trace")]
529 tracing::trace!(resolved_crate = %crate_name, "Resolved to primary crate");
530
531 return Some((crate_name.clone(), *id));
532 }
533 }
534
535 // Priority 3: Prefer any module, then first match
536 if let Some((crate_name, id, _)) = candidates
537 .iter()
538 .find(|(_, _, kind)| *kind == ItemKind::Module)
539 {
540 #[cfg(feature = "trace")]
541 tracing::trace!(resolved_crate = %crate_name, "Resolved to module");
542 return Some((crate_name.clone(), *id));
543 }
544
545 let result = candidates.first().map(|(c, id, _)| (c.clone(), *id));
546
547 #[cfg(feature = "trace")]
548 tracing::trace!(
549 resolved_crate = ?result.as_ref().map(|(c, _)| c),
550 "Resolved to first match"
551 );
552
553 result
554 }
555
556 /// Resolve a full path like `regex_automata::Regex` to its crate and ID.
557 ///
558 /// This is used for resolving external re-exports where `use_item.id` is `None`
559 /// but the source path is available.
560 ///
561 /// # Arguments
562 ///
563 /// * `path` - Full path like `regex_automata::Regex` or `tracing_core::span::Span`
564 ///
565 /// # Returns
566 ///
567 /// The (`crate_name`, `item_id`) if found in the registry.
568 #[must_use]
569 pub fn resolve_path(&self, path: &str) -> Option<(Str, Id)> {
570 let segments: Vec<&str> = path.split(RUST_PATH_SEP).collect();
571
572 if segments.is_empty() {
573 return None;
574 }
575
576 // First segment is the crate name
577 let target_crate = segments[0];
578
579 // Last segment is the item name
580 let item_name = segments.last()?;
581
582 // Look up in name_index and filter by crate
583 let candidates = self.name_index.get(*item_name)?;
584
585 for (crate_name, id, _kind) in candidates {
586 if crate_name == target_crate {
587 return Some((crate_name.clone(), *id));
588 }
589 }
590
591 None
592 }
593
594 /// Create a markdown link from one file to another across crates.
595 ///
596 /// # Arguments
597 ///
598 /// * `from_crate` - The crate where the link appears
599 /// * `from_path` - The file path where the link appears
600 /// * `to_crate` - The target crate
601 /// * `to_id` - The target item's ID
602 ///
603 /// # Returns
604 ///
605 /// A formatted markdown link like `[`Name`](relative/path.md)`,
606 /// or `None` if the target item isn't registered.
607 #[must_use]
608 pub fn create_link(
609 &self,
610 from_crate: &str,
611 from_path: &str,
612 to_crate: &str,
613 to_id: Id,
614 ) -> Option<String> {
615 let target_path = self.get_path(to_crate, to_id)?;
616 let name = self.get_name(to_crate, to_id)?;
617
618 // Build full paths including crate directory
619 let from_full = format!("{from_crate}/{from_path}");
620 let to_full = format!("{to_crate}/{target_path}");
621
622 // Compute relative path
623 let relative = Self::compute_cross_crate_path(&from_full, &to_full);
624
625 // Check if same file - use anchor instead
626 if from_full == to_full {
627 let anchor = AnchorUtils::slugify_anchor(name);
628 return Some(format!("[`{name}`](#{anchor})"));
629 }
630
631 Some(format!("[`{name}`]({relative})"))
632 }
633
634 /// Compute relative path between files potentially in different crates.
635 ///
636 /// # Examples
637 ///
638 /// - `tracing/span/index.md` to `tracing_core/subscriber/index.md`
639 /// = `../../tracing_core/subscriber/index.md`
640 /// - `tracing/index.md` to `tracing/span/index.md`
641 /// = `span/index.md`
642 #[must_use]
643 pub fn compute_cross_crate_path(from: &str, to: &str) -> String {
644 // Delegate to the single-crate implementation - it handles
645 // the path computation correctly regardless of crate boundaries
646 LinkRegistry::compute_relative_path(from, to)
647 }
648
649 /// Get an anchor string for an item within its page.
650 ///
651 /// # Arguments
652 ///
653 /// * `crate_name` - The crate containing the item
654 /// * `id` - The item's ID
655 ///
656 /// # Returns
657 ///
658 /// An anchor like `#span` or `#enter` for linking to specific items.
659 #[must_use]
660 pub fn get_anchor(&self, crate_name: &str, id: Id) -> Option<String> {
661 let name = self.get_name(crate_name, id)?;
662 Some(format!("#{}", AnchorUtils::slugify_anchor(name)))
663 }
664
665 /// Check if an item exists in the registry.
666 ///
667 /// Uses raw entry API for zero-allocation lookup.
668 #[must_use]
669 pub fn contains(&self, crate_name: &str, id: Id) -> bool {
670 use std::hash::BuildHasher;
671 let borrowed = BorrowedKey(crate_name, id);
672 let hash = self.item_paths.hasher().hash_one(&borrowed);
673
674 self.item_paths
675 .raw_entry()
676 .from_hash(hash, |k| keys_match(k, &borrowed))
677 .is_some()
678 }
679
680 /// Get the number of registered items.
681 #[must_use]
682 pub fn len(&self) -> usize {
683 self.item_paths.len()
684 }
685
686 /// Check if the registry is empty.
687 #[must_use]
688 pub fn is_empty(&self) -> bool {
689 self.item_paths.is_empty()
690 }
691}
692
693#[cfg(test)]
694mod tests {
695 use hashbrown::DefaultHashBuilder;
696
697 use super::*;
698
699 #[test]
700 fn test_cross_crate_path_same_crate() {
701 assert_eq!(
702 UnifiedLinkRegistry::compute_cross_crate_path(
703 "tracing/index.md",
704 "tracing/span/index.md"
705 ),
706 "span/index.md"
707 );
708 }
709
710 #[test]
711 fn test_cross_crate_path_different_crates() {
712 assert_eq!(
713 UnifiedLinkRegistry::compute_cross_crate_path(
714 "tracing/span/index.md",
715 "tracing_core/subscriber/index.md"
716 ),
717 "../../tracing_core/subscriber/index.md"
718 );
719 }
720
721 #[test]
722 fn test_cross_crate_path_to_root() {
723 assert_eq!(
724 UnifiedLinkRegistry::compute_cross_crate_path(
725 "tracing/span/enter/index.md",
726 "tracing/index.md"
727 ),
728 "../../index.md"
729 );
730 }
731
732 /// Verify that `BorrowedKey` and `RegistryKey` hash identically.
733 #[test]
734 fn test_borrowed_key_hash_compatibility() {
735 use std::hash::BuildHasher;
736
737 // Use a fixed hasher (same instance for both)
738 let hasher = DefaultHashBuilder::default();
739 let id = Id(42);
740
741 // Create owned key (how it's stored in the HashMap)
742 let owned: RegistryKey = (Str::from("test_crate"), id);
743
744 // Create borrowed key (how we look up)
745 let borrowed = BorrowedKey("test_crate", id);
746
747 // Hashes must be equal for raw_entry lookup to work
748 // Using the SAME hasher instance is critical
749 let owned_hash = hasher.hash_one(&owned);
750 let borrowed_hash = hasher.hash_one(&borrowed);
751
752 assert_eq!(
753 owned_hash, borrowed_hash,
754 "BorrowedKey hash must equal RegistryKey hash"
755 );
756 }
757
758 /// Test that `raw_entry` lookup works correctly.
759 #[test]
760 fn test_raw_entry_lookup() {
761 let mut registry = UnifiedLinkRegistry::default();
762 let id = Id(123);
763
764 // Insert using owned key
765 registry.register_item(
766 "my_crate",
767 id,
768 "MyType",
769 "module/index.md",
770 ItemKind::Struct,
771 );
772
773 // Lookup using borrowed key (zero-allocation)
774 assert!(registry.contains("my_crate", id));
775 assert_eq!(
776 registry.get_path("my_crate", id),
777 Some(&Str::from("module/index.md"))
778 );
779 assert_eq!(
780 registry.get_name("my_crate", id),
781 Some(&Str::from("MyType"))
782 );
783
784 // Non-existent lookups
785 assert!(!registry.contains("other_crate", id));
786 assert!(registry.get_path("other_crate", id).is_none());
787 }
788}