Skip to main content

cargo_rdme/transform/intralinks/
rustdoc.rs

1use crate::PackageTarget;
2use crate::transform::intralinks::ItemPath;
3use crate::transform::intralinks::links::Link;
4use crate::transform::{IntralinkError, IntralinksConfig, IntralinksDocsRsConfig};
5use itertools::Itertools;
6use rustdoc_json::BuildError;
7use rustdoc_types::{
8    Crate, ExternalCrate, Id as ItemId, Impl, Item, ItemEnum, ItemSummary, MacroKind, Primitive,
9    Struct, StructKind, Trait, Type,
10};
11use rustdoc_types::{Enum, ProcMacro, Union};
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14
15// TODO Remove this when rustdoc json stabilizes (https://github.com/rust-lang/rust/issues/76578).
16pub const EXPECTED_RUST_TOOLCHAIN: &str = "nightly-2026-06-22";
17const EXPECTED_RUSTDOC_FORMAT_VERSION: u32 = 57;
18
19pub fn is_expected_rust_toolchain_installed() -> Result<bool, IntralinkError> {
20    rustup_toolchain::is_installed(EXPECTED_RUST_TOOLCHAIN)
21        .map_err(|error| IntralinkError::RustupToolchain { error })
22}
23
24pub fn install_expected_rust_toolchain() -> Result<(), IntralinkError> {
25    rustup_toolchain::install(EXPECTED_RUST_TOOLCHAIN)
26        .map_err(|error| IntralinkError::RustupToolchain { error })
27}
28
29fn crate_from_file(path: &Path) -> Result<Crate, IntralinkError> {
30    let json = std::fs::read_to_string(path)
31        .map_err(|io_error| IntralinkError::ReadRustdocError { io_error })?;
32    serde_json::from_str(&json)
33        .map_err(|serde_error| IntralinkError::ParseRustdocError { serde_error })
34}
35
36fn crate_rustdoc_intralinks(c: &Crate) -> &HashMap<String, ItemId> {
37    &c.index.get(&c.root).expect("root id not present in index").links
38}
39
40#[derive(Debug, Clone)]
41struct ItemInfo<'a> {
42    crate_id: u32,
43    path: ItemPath<'a>,
44    kind: ItemKind,
45    parent_kind: Option<ItemKind>,
46}
47
48impl<'a> ItemInfo<'a> {
49    fn new(
50        crate_id: u32,
51        path: ItemPath<'a>,
52        kind: ItemKind,
53        parent_kind: Option<ItemKind>,
54    ) -> ItemInfo<'a> {
55        ItemInfo { crate_id, path, kind, parent_kind }
56    }
57
58    fn from(
59        item_summary: &'a ItemSummary,
60        parent_kind: Option<ItemKind>,
61        item_context: ItemContext,
62    ) -> ItemInfo<'a> {
63        ItemInfo::new(
64            item_summary.crate_id,
65            ItemPath::new(&item_summary.path),
66            ItemKind::from_rustdoc_item_kind(item_summary.kind, item_context),
67            parent_kind,
68        )
69    }
70
71    /// Merges all the information of both items.
72    fn merge(&self, other: &ItemInfo<'a>) -> Option<ItemInfo<'a>> {
73        if self.crate_id != other.crate_id {
74            return None;
75        }
76        if self.path != other.path {
77            return None;
78        }
79        if self.kind != other.kind {
80            return None;
81        }
82
83        if self.parent_kind.zip(other.parent_kind).is_some_and(|(s, o)| s != o) {
84            return None;
85        }
86
87        let merged = ItemInfo {
88            crate_id: self.crate_id,
89            path: self.path.clone(),
90            kind: self.kind,
91            parent_kind: self.parent_kind.or(other.parent_kind),
92        };
93
94        Some(merged)
95    }
96}
97
98#[derive(PartialEq, Eq, Clone, Copy, Debug)]
99pub enum ItemKind {
100    Module,
101    ExternCrate,
102    Use,
103    Struct,
104    StructField,
105    Union,
106    Enum,
107    Variant,
108    Function,
109    TypeAlias,
110    Constant,
111    Trait,
112    TraitAlias,
113    Impl,
114    Static,
115    ExternType,
116    Macro,
117    ProcAttribute,
118    ProcDerive,
119    AssocConst,
120    AssocType,
121    Primitive,
122    Keyword,
123    Attribute,
124
125    // Kinds that do not exist in rustdoc_types::ItemKind:
126    Method,
127    TyMethod,
128}
129
130impl ItemKind {
131    fn from_rustdoc_item_kind(
132        kind: rustdoc_types::ItemKind,
133        item_context: ItemContext,
134    ) -> ItemKind {
135        match kind {
136            rustdoc_types::ItemKind::Module => ItemKind::Module,
137            rustdoc_types::ItemKind::ExternCrate => ItemKind::ExternCrate,
138            rustdoc_types::ItemKind::Use => ItemKind::Use,
139            rustdoc_types::ItemKind::Struct => ItemKind::Struct,
140            rustdoc_types::ItemKind::StructField => ItemKind::StructField,
141            rustdoc_types::ItemKind::Union => ItemKind::Union,
142            rustdoc_types::ItemKind::Enum => ItemKind::Enum,
143            rustdoc_types::ItemKind::Variant => ItemKind::Variant,
144            rustdoc_types::ItemKind::Function => match item_context {
145                ItemContext::Normal => ItemKind::Function,
146                ItemContext::Impl => ItemKind::Method,
147                ItemContext::Trait => ItemKind::TyMethod,
148            },
149            rustdoc_types::ItemKind::TypeAlias => ItemKind::TypeAlias,
150            rustdoc_types::ItemKind::Constant => ItemKind::Constant,
151            rustdoc_types::ItemKind::Trait => ItemKind::Trait,
152            rustdoc_types::ItemKind::TraitAlias => ItemKind::TraitAlias,
153            rustdoc_types::ItemKind::Impl => ItemKind::Impl,
154            rustdoc_types::ItemKind::Static => ItemKind::Static,
155            rustdoc_types::ItemKind::ExternType => ItemKind::ExternType,
156            rustdoc_types::ItemKind::Macro => ItemKind::Macro,
157            rustdoc_types::ItemKind::ProcAttribute => ItemKind::ProcAttribute,
158            rustdoc_types::ItemKind::ProcDerive => ItemKind::ProcDerive,
159            rustdoc_types::ItemKind::AssocConst => ItemKind::AssocConst,
160            rustdoc_types::ItemKind::AssocType => ItemKind::AssocType,
161            rustdoc_types::ItemKind::Primitive => ItemKind::Primitive,
162            rustdoc_types::ItemKind::Keyword => ItemKind::Keyword,
163            rustdoc_types::ItemKind::Attribute => ItemKind::Attribute,
164        }
165    }
166
167    fn of_item(item: &Item, item_context: ItemContext) -> ItemKind {
168        match item.inner {
169            ItemEnum::Module(_) => ItemKind::Module,
170            ItemEnum::ExternCrate { .. } => ItemKind::ExternCrate,
171            ItemEnum::Use(_) => ItemKind::Use,
172            ItemEnum::Union(_) => ItemKind::Union,
173            ItemEnum::Struct(_) => ItemKind::Struct,
174            ItemEnum::StructField(_) => ItemKind::StructField,
175            ItemEnum::Enum(_) => ItemKind::Enum,
176            ItemEnum::Variant(_) => ItemKind::Variant,
177            ItemEnum::Function(_) => match item_context {
178                ItemContext::Normal => ItemKind::Function,
179                ItemContext::Impl => ItemKind::Method,
180                ItemContext::Trait => ItemKind::TyMethod,
181            },
182            ItemEnum::Trait(_) => ItemKind::Trait,
183            ItemEnum::TraitAlias(_) => ItemKind::TraitAlias,
184            ItemEnum::Impl(_) => ItemKind::Impl,
185            ItemEnum::TypeAlias(_) => ItemKind::TypeAlias,
186            ItemEnum::Constant { .. } => ItemKind::Constant,
187            ItemEnum::Static(_) => ItemKind::Static,
188            ItemEnum::ExternType => ItemKind::ExternType,
189            ItemEnum::Macro(_) => ItemKind::Macro,
190            ItemEnum::ProcMacro(ProcMacro { kind: MacroKind::Bang, .. }) => ItemKind::Macro,
191            ItemEnum::ProcMacro(ProcMacro { kind: MacroKind::Derive, .. }) => ItemKind::ProcDerive,
192            ItemEnum::ProcMacro(ProcMacro { kind: MacroKind::Attr, .. }) => ItemKind::ProcAttribute,
193            ItemEnum::Primitive(_) => ItemKind::Primitive,
194            ItemEnum::AssocConst { .. } => ItemKind::AssocConst,
195            ItemEnum::AssocType { .. } => ItemKind::AssocType,
196        }
197    }
198}
199
200fn child_item_ids<'a>(item: &'a Item) -> Box<dyn Iterator<Item = ItemId> + 'a> {
201    match &item.inner {
202        ItemEnum::Struct(Struct { kind, impls, .. }) => {
203            let fields_ids: Box<dyn Iterator<Item = ItemId>> = match kind {
204                StructKind::Unit => Box::new(std::iter::empty()),
205                StructKind::Tuple(ids) => Box::new(ids.iter().copied().flatten()),
206                StructKind::Plain { fields, .. } => Box::new(fields.iter().copied()),
207            };
208
209            Box::new(fields_ids.chain(impls.iter().copied()))
210        }
211        ItemEnum::Impl(Impl { trait_: Some(_), .. }) => Box::new(std::iter::empty()),
212        ItemEnum::Impl(Impl { items: item_ids, for_, .. }) => match for_ {
213            Type::ResolvedPath(_) => Box::new(item_ids.iter().copied()),
214            _ => Box::new(std::iter::empty()),
215        },
216        ItemEnum::Union(Union { fields, impls, .. }) => {
217            Box::new(fields.iter().chain(impls.iter()).copied())
218        }
219        ItemEnum::Enum(Enum { variants, impls, .. }) => {
220            Box::new(variants.iter().chain(impls.iter()).copied())
221        }
222        ItemEnum::Primitive(Primitive { impls, .. }) => Box::new(impls.iter().copied()),
223        ItemEnum::Trait(Trait { items, .. }) => {
224            // We ignore the implementations of the trait as their items are not part of the trait
225            // itself.
226            Box::new(items.iter().copied())
227        }
228
229        ItemEnum::Function(_)
230        | ItemEnum::ExternCrate { .. }
231        | ItemEnum::Use(_)
232        | ItemEnum::Module(_)
233        | ItemEnum::Constant { .. }
234        | ItemEnum::Static(_)
235        | ItemEnum::Macro(_)
236        | ItemEnum::ProcMacro(_)
237        | ItemEnum::AssocConst { .. }
238        | ItemEnum::AssocType { .. }
239        | ItemEnum::StructField(_)
240        | ItemEnum::Variant(_)
241        | ItemEnum::ExternType
242        | ItemEnum::TraitAlias(_)
243        | ItemEnum::TypeAlias(_) => Box::new(std::iter::empty()),
244    }
245}
246
247#[derive(Clone, Copy, Debug)]
248enum ItemContext {
249    Normal,
250    Impl,
251    Trait,
252}
253
254fn get_item_info<'a>(
255    item_id: ItemId,
256    parent_path: &ItemPath<'a>,
257    parent_kind: Option<ItemKind>,
258    item_context: ItemContext,
259    rustdoc_crate: &'a Crate,
260) -> Option<ItemInfo<'a>> {
261    match rustdoc_crate.paths.get(&item_id) {
262        Some(item_summary) => Some(ItemInfo::from(item_summary, parent_kind, item_context)),
263        None => rustdoc_crate.index.get(&item_id).map(|item| {
264            let path = match item.name.as_ref() {
265                None => parent_path.clone(),
266                Some(name) => parent_path.add(name.clone()),
267            };
268            let item_kind = ItemKind::of_item(item, item_context);
269
270            ItemInfo::new(item.crate_id, path, item_kind, parent_kind)
271        }),
272    }
273}
274
275fn transitive_items<'a>(
276    item_id: ItemId,
277    item_info: &ItemInfo<'a>,
278    item_context: ItemContext,
279    rustdoc_crate: &'a Crate,
280    items_info: &mut HashMap<ItemId, ItemInfo<'a>>,
281) {
282    if item_info.kind != ItemKind::Impl {
283        items_info
284            .entry(item_id)
285            .and_modify(|existing_item_info| {
286                *existing_item_info =
287                    existing_item_info.merge(item_info).expect("unmergeable item info");
288            })
289            .or_insert_with(|| item_info.clone());
290    }
291
292    let Some(item) = rustdoc_crate.index.get(&item_id) else {
293        // This item is not in the index for some reason...
294        return;
295    };
296
297    let inner_item_context = match item.inner {
298        ItemEnum::Trait(_) => ItemContext::Trait,
299        ItemEnum::Impl(_) => ItemContext::Impl,
300        _ => item_context,
301    };
302
303    for inner_item_id in child_item_ids(item) {
304        // The inner_item_parent_kind is not just `item_info.kind` because we need to skip
305        // kinds like `impl` blocks.
306        let inner_item_parent_kind = match item.name {
307            Some(_) => Some(item_info.kind),
308            None => item_info.parent_kind,
309        };
310
311        let inner_item_info = get_item_info(
312            inner_item_id,
313            &item_info.path,
314            inner_item_parent_kind,
315            inner_item_context,
316            rustdoc_crate,
317        );
318
319        if let Some(inner_item_info) = inner_item_info {
320            transitive_items(
321                inner_item_id,
322                &inner_item_info,
323                inner_item_context,
324                rustdoc_crate,
325                items_info,
326            );
327        }
328    }
329}
330
331pub struct IntralinkResolver<'a> {
332    link_url: HashMap<Link, String>,
333    config: &'a IntralinksDocsRsConfig,
334    package_name: &'a str,
335}
336
337impl<'a> IntralinkResolver<'a> {
338    pub fn new(package_name: &'a str, config: &'a IntralinksDocsRsConfig) -> IntralinkResolver<'a> {
339        IntralinkResolver { link_url: HashMap::new(), package_name, config }
340    }
341
342    fn url_segment(kind: ItemKind, name: &str) -> String {
343        match kind {
344            ItemKind::Module => format!("{name}/"),
345            ItemKind::Struct => format!("struct.{name}.html"),
346            ItemKind::StructField => format!("#structfield.{name}"),
347            ItemKind::Union => format!("union.{name}.html"),
348            ItemKind::Enum => format!("enum.{name}.html"),
349            ItemKind::Variant => format!("#variant.{name}"),
350            ItemKind::Function => format!("fn.{name}.html"),
351            ItemKind::Method => format!("#method.{name}"),
352            ItemKind::TyMethod => format!("#tymethod.{name}"),
353            ItemKind::TypeAlias => format!("type.{name}.html"),
354            ItemKind::Constant => format!("const.{name}.html"),
355            ItemKind::Trait => format!("trait.{name}.html"),
356            ItemKind::TraitAlias => format!("traitalias.{name}.html"),
357            ItemKind::Static => format!("static.{name}.html"),
358            ItemKind::Macro => format!("macro.{name}.html"),
359            ItemKind::ProcAttribute => format!("attr.{name}.html"),
360            ItemKind::ProcDerive => format!("derive.{name}.html"),
361            ItemKind::AssocConst => {
362                format!("#associatedconstant.{name}")
363            }
364            ItemKind::AssocType => format!("#associatedtype.{name}"),
365            ItemKind::Primitive => format!("primitive.{name}.html"),
366
367            ItemKind::Keyword
368            | ItemKind::ExternCrate
369            | ItemKind::Use
370            | ItemKind::Impl
371            | ItemKind::ExternType
372            | ItemKind::Attribute => {
373                unreachable!("items of kind {:?} cannot be intralinked to", kind);
374            }
375        }
376    }
377
378    fn is_stdlib_crate(external_crate: &ExternalCrate) -> bool {
379        external_crate
380            .html_root_url
381            .as_deref()
382            .is_some_and(|base_url| base_url.starts_with("https://doc.rust-lang.org/"))
383    }
384
385    fn make_url(base_url: &str, package_name: &str, version: &str, url_path: &str) -> String {
386        format!("{base_url}/{package_name}/{version}/{url_path}")
387    }
388
389    fn add(
390        &mut self,
391        link: Link,
392        item_info: &ItemInfo,
393        external_crates: &HashMap<u32, ExternalCrate>,
394    ) {
395        let docs_rs_base_url = self.config.docs_rs_base_url.as_deref().unwrap_or("https://docs.rs");
396
397        let path_segment_kind = |i: usize| match item_info.path.len() - i {
398            1 => item_info.kind,
399            2 => item_info.parent_kind.unwrap_or(ItemKind::Module),
400            _ => ItemKind::Module,
401        };
402        let url_path = item_info
403            .path
404            .segments()
405            .enumerate()
406            .map(|(i, segment)| (segment, path_segment_kind(i)))
407            .map(|(segment, item_kind)| IntralinkResolver::url_segment(item_kind, segment))
408            .join("");
409
410        let url = match item_info.crate_id {
411            // Local crate has id 0.
412            0 => {
413                let version = self.config.docs_rs_version.as_deref().unwrap_or("latest");
414                let package_name = &self.package_name;
415
416                Self::make_url(docs_rs_base_url, package_name, version, &url_path)
417            }
418            // External crate
419            _ => {
420                let Some(external_crate) = external_crates.get(&item_info.crate_id) else {
421                    return;
422                };
423
424                match external_crate.html_root_url.as_deref() {
425                    Some(base_url) => {
426                        let base_url = match Self::is_stdlib_crate(external_crate) {
427                            true => {
428                                // TODO Once we are able to use the stable version we can remove this
429                                //      (https://github.com/rust-lang/rust/issues/76578).
430                                base_url
431                                    .strip_suffix("/nightly/")
432                                    .map_or_else(|| base_url.to_owned(), |p| format!("{p}/stable/"))
433                            }
434                            false => base_url.to_owned(),
435                        };
436
437                        format!("{base_url}{url_path}")
438                    }
439                    None => {
440                        let crate_name = &external_crate.name;
441
442                        // TODO We are using the crate name instead of the package name: that means that
443                        //      we might generate a wrong url. In most cases the crate name matches the
444                        //      package name. When it doesn't it is often because underscores in the
445                        //      crate name becomes dashes in the package name. Fortunately `docs.rs`
446                        //      will redirect in that case (e.g. https://docs.rs/tower_service/ will
447                        //      redirect to https://docs.rs/tower-service/latest/tower_service/).
448                        // TODO We shouldn't hardcode "latest" here: we should get that information from
449                        //      the version rustdoc determined the crate was using.
450                        Self::make_url(docs_rs_base_url, crate_name, "latest", &url_path)
451                    }
452                }
453            }
454        };
455
456        self.link_url.insert(link, url);
457    }
458
459    pub fn resolve_link(&self, link: &Link) -> Option<&str> {
460        self.link_url.get(link).map(String::as_str)
461    }
462
463    pub fn is_intralink(link: &Link) -> bool {
464        let has_lone_colon = || link.raw_link.replace("::", "").contains(':');
465
466        !link.symbol().is_empty() && !link.raw_link.contains('/') && !has_lone_colon()
467    }
468}
469
470fn run_rustdoc(
471    package_target: &PackageTarget,
472    workspace_package: Option<&str>,
473    manifest_path: &PathBuf,
474    config: &IntralinksConfig,
475) -> Result<Crate, IntralinkError> {
476    let rustdoc_json_path: PathBuf = {
477        let target: rustdoc_json::PackageTarget = match package_target {
478            PackageTarget::Bin { crate_name } => {
479                rustdoc_json::PackageTarget::Bin(crate_name.clone())
480            }
481            PackageTarget::Lib => rustdoc_json::PackageTarget::Lib,
482        };
483        let mut stderr = Vec::new();
484
485        let toolchain = match is_expected_rust_toolchain_installed()? {
486            true => EXPECTED_RUST_TOOLCHAIN,
487            false => {
488                return Err(IntralinkError::RustToolchainNotInstalled {
489                    expected: EXPECTED_RUST_TOOLCHAIN,
490                });
491            }
492        };
493
494        let mut builder = rustdoc_json::Builder::default()
495            .toolchain(toolchain)
496            .manifest_path(manifest_path)
497            .document_private_items(true)
498            .all_features(config.all_features.unwrap_or_default())
499            .features(config.features.clone().unwrap_or_default())
500            .no_default_features(config.no_default_features.unwrap_or_default())
501            .quiet(true)
502            .color(rustdoc_json::Color::Never)
503            .package_target(target);
504
505        if let Some(package) = workspace_package {
506            builder = builder.package(package);
507        }
508
509        let result = builder.build_with_captured_output(std::io::sink(), &mut stderr);
510
511        result.map_err(|error| match error {
512            BuildError::BuildRustdocJsonError => match stderr.is_empty() {
513                true => IntralinkError::BuildRustdocError {
514                    stderr: "Weirdly, rustdoc did not write anything to stderr".to_owned(),
515                },
516                false => IntralinkError::BuildRustdocError {
517                    stderr: String::from_utf8_lossy(&stderr).into_owned(),
518                },
519            },
520            e => IntralinkError::RustdocError { error: e },
521        })?
522    };
523
524    let rustdoc_crate = crate_from_file(&rustdoc_json_path)?;
525
526    match rustdoc_crate.format_version {
527        EXPECTED_RUSTDOC_FORMAT_VERSION => Ok(rustdoc_crate),
528        format_version => Err(IntralinkError::UnsupportedRustdocFormatVersion {
529            version: format_version,
530            expected_version: EXPECTED_RUSTDOC_FORMAT_VERSION,
531        }),
532    }
533}
534
535fn items_info(rustdoc_crate: &Crate) -> HashMap<ItemId, ItemInfo<'_>> {
536    let mut items_info: HashMap<ItemId, ItemInfo<'_>> =
537        HashMap::with_capacity(rustdoc_crate.index.len());
538
539    for (&item_id, item_summary) in &rustdoc_crate.paths {
540        let item_info = ItemInfo::from(item_summary, None, ItemContext::Normal);
541
542        transitive_items(item_id, &item_info, ItemContext::Normal, rustdoc_crate, &mut items_info);
543    }
544
545    items_info
546}
547
548pub fn create_intralink_resolver<'a>(
549    package_name: &'a str,
550    package_target: &PackageTarget,
551    workspace_package: Option<&str>,
552    manifest_path: &PathBuf,
553    config: &'a IntralinksConfig,
554) -> Result<IntralinkResolver<'a>, IntralinkError> {
555    let rustdoc_crate = run_rustdoc(package_target, workspace_package, manifest_path, config)?;
556
557    let items_info: HashMap<ItemId, ItemInfo<'_>> = items_info(&rustdoc_crate);
558    let links_items_id = crate_rustdoc_intralinks(&rustdoc_crate);
559    let mut intralink_resolver = IntralinkResolver::new(package_name, &config.docs_rs);
560
561    for (link, item_id) in links_items_id {
562        let link = Link::new(link.clone());
563        let Some(item_info) = items_info.get(item_id) else {
564            // We will fail when we try to create the link and will emit a warning there.
565            continue;
566        };
567
568        intralink_resolver.add(link, item_info, &rustdoc_crate.external_crates);
569    }
570
571    Ok(intralink_resolver)
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use pretty_assertions::assert_eq;
578
579    #[test]
580    fn test_rustdoc_format_supported_version() {
581        assert_eq!(rustdoc_types::FORMAT_VERSION, EXPECTED_RUSTDOC_FORMAT_VERSION);
582    }
583
584    fn make_item_info(
585        crate_id: u32,
586        path: &'static [&'static str],
587        kind: ItemKind,
588        parent_kind: Option<ItemKind>,
589    ) -> ItemInfo<'static> {
590        let segments: &'static [String] = Box::leak(
591            path.iter().map(|&s| s.to_owned()).collect::<Vec<String>>().into_boxed_slice(),
592        );
593
594        ItemInfo::new(crate_id, ItemPath::new(segments), kind, parent_kind)
595    }
596
597    #[test]
598    fn test_item_info_merge_identical() {
599        let a = make_item_info(0, &["foo", "Bar"], ItemKind::Struct, Some(ItemKind::Module));
600        let b = a.clone();
601
602        let merged = a.merge(&b).expect("identical items should merge");
603
604        assert_eq!(merged.crate_id, 0);
605        assert_eq!(merged.kind, ItemKind::Struct);
606        assert_eq!(merged.parent_kind, Some(ItemKind::Module));
607    }
608
609    #[test]
610    fn test_item_info_merge_fills_missing_parent_kind() {
611        let with_parent =
612            make_item_info(0, &["foo", "Bar"], ItemKind::Struct, Some(ItemKind::Module));
613        let without_parent = make_item_info(0, &["foo", "Bar"], ItemKind::Struct, None);
614
615        let merged_a = with_parent.merge(&without_parent).expect("compatible parent_kinds");
616        let merged_b = without_parent.merge(&with_parent).expect("compatible parent_kinds");
617
618        assert_eq!(merged_a.parent_kind, Some(ItemKind::Module));
619        assert_eq!(merged_b.parent_kind, Some(ItemKind::Module));
620    }
621
622    #[test]
623    fn test_item_info_merge_rejects_mismatch() {
624        let base = make_item_info(0, &["foo", "Bar"], ItemKind::Struct, Some(ItemKind::Module));
625
626        let different_crate =
627            make_item_info(1, &["foo", "Bar"], ItemKind::Struct, Some(ItemKind::Module));
628        let different_path =
629            make_item_info(0, &["other", "Bar"], ItemKind::Struct, Some(ItemKind::Module));
630        let different_kind =
631            make_item_info(0, &["foo", "Bar"], ItemKind::Enum, Some(ItemKind::Module));
632        let different_parent =
633            make_item_info(0, &["foo", "Bar"], ItemKind::Struct, Some(ItemKind::Struct));
634
635        assert!(base.merge(&different_crate).is_none());
636        assert!(base.merge(&different_path).is_none());
637        assert!(base.merge(&different_kind).is_none());
638        assert!(base.merge(&different_parent).is_none());
639    }
640
641    #[test]
642    fn test_is_intralink_rejects_paths_with_slash() {
643        assert!(!IntralinkResolver::is_intralink(&Link::new("foo/bar".to_owned())));
644        assert!(!IntralinkResolver::is_intralink(&Link::new("/abs/path".to_owned())));
645        assert!(!IntralinkResolver::is_intralink(&Link::new("./relative".to_owned())));
646        assert!(!IntralinkResolver::is_intralink(&Link::new("https://example.com".to_owned())));
647    }
648
649    #[test]
650    fn test_is_intralink_accepts_paths() {
651        assert!(IntralinkResolver::is_intralink(&Link::new("Foo".to_owned())));
652        assert!(IntralinkResolver::is_intralink(&Link::new("crate::Foo".to_owned())));
653        assert!(IntralinkResolver::is_intralink(&Link::new("::std::vec::Vec".to_owned())));
654        assert!(IntralinkResolver::is_intralink(&Link::new("type@crate::Foo".to_owned())));
655    }
656}