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}