Skip to main content

okf_core/
bundle.rs

1//! Loading and traversing an OKF *bundle*: a directory tree of markdown files.
2//!
3//! [`Bundle::load`] walks a directory, parses every non-reserved `.md` file
4//! into a [`Concept`], records the reserved `index.md` / `log.md` files, and
5//! builds two graphs over the result:
6//!
7//! - the **cross-link graph** from markdown links, with backlinks;
8//! - the **derivation graph** from `sources[].resource` entries that name
9//!   another concept, which is how credibility propagates: "when a
10//!   `resource` points at another OKF concept, the derivation edge already
11//!   exists in the bundle graph, so a consumer MAY recurse into that source's
12//!   own `sources`."
13//!
14//! Loading is **permissive** by design: files whose frontmatter cannot be
15//! parsed are collected into [`Bundle::parse_errors`] rather than aborting the
16//! load, and broken links are retained as edges to non-existent concepts.
17
18use crate::computation::AttestedComputation;
19use crate::concept_id::ConceptId;
20use crate::date::{Date, DateTime};
21use crate::document::Document;
22use crate::error::{BundleError, DocumentError};
23use crate::links;
24use crate::provenance::Source;
25use crate::trust::{Status, TrustTier};
26use crate::yaml::Value;
27use std::borrow::Cow;
28use std::collections::{BTreeMap, HashMap};
29use std::fs;
30use std::path::{Path, PathBuf};
31
32/// Reserved filenames with defined meaning at any level.
33pub const RESERVED_FILENAMES: [&str; 2] = ["index.md", "log.md"];
34
35/// A single concept within a bundle (one markdown document).
36#[derive(Clone, Debug)]
37pub struct Concept {
38    /// The concept's id (path minus `.md`).
39    pub id: ConceptId,
40    /// The file path on disk.
41    pub path: PathBuf,
42    /// The parsed document.
43    pub document: Document,
44}
45
46impl Concept {
47    /// The concept's `type`.
48    #[must_use]
49    pub fn type_(&self) -> Option<Cow<'_, str>> {
50        self.document.frontmatter.type_()
51    }
52
53    /// The concept's `title`, falling back to the final segment of its id when
54    /// none is given, as the spec permits.
55    #[must_use]
56    pub fn display_title(&self) -> String {
57        self.document
58            .frontmatter
59            .title()
60            .map_or_else(|| self.id.name().to_string(), std::borrow::Cow::into_owned)
61    }
62
63    /// The trust tier derived from `verified`.
64    #[must_use]
65    pub fn trust_tier(&self) -> TrustTier {
66        self.document.frontmatter.trust_tier()
67    }
68
69    /// The lifecycle `status`; absent means stable.
70    #[must_use]
71    pub fn status(&self) -> Status {
72        self.document.frontmatter.status()
73    }
74
75    /// Whether this concept is stale at `now`: `now >= stale_after`.
76    #[must_use]
77    pub fn is_stale_at(&self, now: DateTime) -> bool {
78        self.document.frontmatter.is_stale_at(now)
79    }
80
81    /// Whether `today >= stale_after`.
82    #[must_use]
83    pub fn is_stale_on(&self, today: Date) -> bool {
84        self.document.frontmatter.is_stale_on(today)
85    }
86
87    /// The `sources` this concept derives from.
88    #[must_use]
89    pub fn sources(&self) -> Vec<Source> {
90        self.document.frontmatter.sources()
91    }
92
93    /// The Attested Computation contract, when this concept is one.
94    #[must_use]
95    pub fn attested_computation(&self) -> Option<AttestedComputation> {
96        self.document.attested_computation()
97    }
98}
99
100/// A cross-link from one concept to another, after resolution.
101#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct ResolvedLink {
103    /// The concept the link points at.
104    pub target: ConceptId,
105    /// Whether the target concept exists in the bundle. A `false` is allowed:
106    /// broken links are not malformed, they may be not-yet-written knowledge.
107    pub exists: bool,
108    /// The link text.
109    pub text: String,
110    /// The raw link target as written.
111    pub raw: String,
112}
113
114/// A `sources` entry resolved against the bundle.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ResolvedSource {
117    /// The entry as written in frontmatter.
118    pub source: Source,
119    /// The concept the entry's `resource` names, when it names one that exists
120    /// in this bundle. External URLs and scope descriptors leave this `None`.
121    pub concept: Option<ConceptId>,
122}
123
124/// A loaded OKF bundle.
125#[derive(Debug)]
126pub struct Bundle {
127    root: PathBuf,
128    concepts: Vec<Concept>,
129    index: HashMap<ConceptId, usize>,
130    index_files: Vec<PathBuf>,
131    log_files: Vec<PathBuf>,
132    parse_errors: Vec<(PathBuf, DocumentError)>,
133    outbound: HashMap<ConceptId, Vec<ResolvedLink>>,
134    backlinks: HashMap<ConceptId, Vec<ConceptId>>,
135    sources: HashMap<ConceptId, Vec<ResolvedSource>>,
136    derived_by: HashMap<ConceptId, Vec<ConceptId>>,
137    /// The `okf_version` declared in the bundle-root `index.md` frontmatter,
138    /// if any. Cached at load time so [`Bundle::okf_version`] can borrow
139    /// instead of re-reading the file on every call.
140    okf_version: Option<String>,
141}
142
143impl Bundle {
144    /// Loads a bundle from a directory tree.
145    ///
146    /// Returns an error only for I/O failures or a non-directory root. Per-file
147    /// parse failures are recorded in [`Bundle::parse_errors`].
148    ///
149    /// # Errors
150    ///
151    /// Returns [`BundleError::NotADirectory`] if `root` does not exist or is
152    /// not a directory, and [`BundleError::Io`] for any underlying I/O failure
153    /// while walking the tree.
154    pub fn load(root: impl AsRef<Path>) -> Result<Self, BundleError> {
155        let root = root.as_ref().to_path_buf();
156        if !root.is_dir() {
157            return Err(BundleError::NotADirectory(root));
158        }
159
160        let mut md_files = Vec::new();
161        collect_markdown(&root, &mut md_files)?;
162        md_files.sort();
163
164        // Parse every non-reserved file in parallel. The work per file is
165        // I/O-bound (`fs::read_to_string`) followed by CPU-bound
166        // (`Document::parse`), so parallelizing across the file list scales
167        // with cores on large bundles via `std::thread::scope`. Results are merged in chunk order so the
168        // vectors below retain the deterministic sorted order callers rely on.
169        let outcomes = parse_files_parallel(&root, &md_files)?;
170
171        let mut concepts = Vec::new();
172        let mut index_files = Vec::new();
173        let mut log_files = Vec::new();
174        let mut parse_errors = Vec::new();
175        for outcome in outcomes {
176            match outcome {
177                FileOutcome::Index(p) => index_files.push(p),
178                FileOutcome::Log(p) => log_files.push(p),
179                FileOutcome::Concept(c) => concepts.push(c),
180                FileOutcome::Error(p, e) => parse_errors.push((p, e)),
181            }
182        }
183
184        let mut index = HashMap::new();
185        for (i, c) in concepts.iter().enumerate() {
186            index.insert(c.id.clone(), i);
187        }
188
189        let (outbound, backlinks) = build_graph(&concepts, &index);
190        let (sources, derived_by) = build_derivation_graph(&concepts, &index);
191
192        // Cache the `okf_version` from the bundle-root `index.md` frontmatter,
193        // if any, so `Bundle::okf_version` does not re-read the file on
194        // every call.
195        let okf_version = read_okf_version(&root);
196
197        Ok(Self {
198            root,
199            concepts,
200            index,
201            index_files,
202            log_files,
203            parse_errors,
204            outbound,
205            backlinks,
206            sources,
207            derived_by,
208            okf_version,
209        })
210    }
211
212    /// Loads a single concept file into a one-concept bundle.
213    ///
214    /// If the file is inside a directory tree, the file's parent is used as the bundle root.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`BundleError::Io`] on filesystem read errors.
219    pub fn load_file(path: impl AsRef<Path>) -> Result<Self, BundleError> {
220        let path = path.as_ref().to_path_buf();
221        if !path.is_file() {
222            return Err(std::io::Error::new(
223                std::io::ErrorKind::NotFound,
224                format!("not a file: {}", path.display()),
225            )
226            .into());
227        }
228        let root = path
229            .parent()
230            .unwrap_or_else(|| Path::new("."))
231            .to_path_buf();
232        let text = fs::read_to_string(&path)?;
233        let outcome = match Document::parse(&text) {
234            Ok(document) => match ConceptId::from_path(&root, &path) {
235                Ok(id) => FileOutcome::Concept(Concept { id, path, document }),
236                Err(e) => FileOutcome::Error(path, e.into()),
237            },
238            Err(e) => FileOutcome::Error(path, e),
239        };
240
241        let mut concepts = Vec::new();
242        let mut parse_errors = Vec::new();
243        match outcome {
244            FileOutcome::Concept(c) => concepts.push(c),
245            FileOutcome::Error(p, e) => parse_errors.push((p, e)),
246            _ => {}
247        }
248
249        let mut index = HashMap::new();
250        for (i, c) in concepts.iter().enumerate() {
251            index.insert(c.id.clone(), i);
252        }
253
254        let (outbound, backlinks) = build_graph(&concepts, &index);
255        let (sources, derived_by) = build_derivation_graph(&concepts, &index);
256        let okf_version = read_okf_version(&root);
257
258        Ok(Self {
259            root,
260            concepts,
261            index,
262            index_files: Vec::new(),
263            log_files: Vec::new(),
264            parse_errors,
265            outbound,
266            backlinks,
267            sources,
268            derived_by,
269            okf_version,
270        })
271    }
272
273    /// The bundle's root directory.
274    #[must_use]
275    pub fn root(&self) -> &Path {
276        &self.root
277    }
278
279    /// All successfully parsed concepts, in path order.
280    #[must_use]
281    pub fn concepts(&self) -> &[Concept] {
282        &self.concepts
283    }
284
285    /// Number of concepts.
286    #[must_use]
287    pub const fn len(&self) -> usize {
288        self.concepts.len()
289    }
290
291    /// `true` if the bundle has no concepts.
292    #[must_use]
293    pub const fn is_empty(&self) -> bool {
294        self.concepts.is_empty()
295    }
296
297    /// Looks up a concept by id.
298    #[must_use]
299    pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
300        self.index.get(id).map(|&i| &self.concepts[i])
301    }
302
303    /// `true` if a concept with this id exists.
304    #[must_use]
305    pub fn contains(&self, id: &ConceptId) -> bool {
306        self.index.contains_key(id)
307    }
308
309    /// Paths of all `index.md` files found.
310    #[must_use]
311    pub fn index_files(&self) -> &[PathBuf] {
312        &self.index_files
313    }
314
315    /// Paths of all `log.md` files found.
316    #[must_use]
317    pub fn log_files(&self) -> &[PathBuf] {
318        &self.log_files
319    }
320
321    /// Files whose frontmatter could not be parsed during loading.
322    #[must_use]
323    pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
324        &self.parse_errors
325    }
326
327    /// The resolved outbound cross-links from a concept.
328    #[must_use]
329    pub fn links_from(&self, id: &ConceptId) -> &[ResolvedLink] {
330        self.outbound.get(id).map_or(&[], std::vec::Vec::as_slice)
331    }
332
333    /// The ids of concepts that link to the given concept ("cited by" /
334    /// backlinks).
335    #[must_use]
336    pub fn backlinks(&self, id: &ConceptId) -> &[ConceptId] {
337        self.backlinks.get(id).map_or(&[], std::vec::Vec::as_slice)
338    }
339
340    /// All broken internal links in the bundle, as `(source, raw_target)`
341    /// pairs. Broken links are permitted by the spec, so this is
342    /// informational.
343    #[must_use]
344    pub fn broken_links(&self) -> Vec<(ConceptId, String)> {
345        let mut out = Vec::new();
346        for c in &self.concepts {
347            for link in self.links_from(&c.id) {
348                if !link.exists {
349                    out.push((c.id.clone(), link.raw.clone()));
350                }
351            }
352        }
353        out
354    }
355
356    /// The declared OKF version from the bundle-root `index.md` frontmatter, if
357    /// present (`okf_version`). This is the only place frontmatter is
358    /// permitted in an `index.md`.
359    ///
360    /// Cached at load time, so this is cheap to call repeatedly. A consumer
361    /// that does not understand the declared version SHOULD attempt best-effort
362    /// consumption rather than refusing the bundle, so this is reported, never
363    /// enforced.
364    ///
365    /// Returns `None` whether the root `index.md` is absent, unreadable, or
366    /// lacks the key; a malformed root `index.md` is reported separately by
367    /// `validate_bundle` (in the okf-validator crate).
368    #[must_use]
369    pub fn okf_version(&self) -> Option<&str> {
370        self.okf_version.as_deref()
371    }
372
373    /// The concept's `sources` entries, each resolved against the bundle.
374    #[must_use]
375    pub fn sources_of(&self, id: &ConceptId) -> &[ResolvedSource] {
376        self.sources.get(id).map_or(&[], std::vec::Vec::as_slice)
377    }
378
379    /// The concepts this one derives from: the `sources[].resource` entries
380    /// that name another concept in this bundle.
381    ///
382    /// Following these recursively is how a consumer lets credibility
383    /// propagate; external leaf sources carry only their intrinsic signals.
384    #[must_use]
385    pub fn derived_from(&self, id: &ConceptId) -> Vec<&ConceptId> {
386        self.sources_of(id)
387            .iter()
388            .filter_map(|s| s.concept.as_ref())
389            .collect()
390    }
391
392    /// The reverse of [`Bundle::derived_from`]: concepts that cite this one as
393    /// a source.
394    #[must_use]
395    pub fn derives(&self, id: &ConceptId) -> &[ConceptId] {
396        self.derived_by.get(id).map_or(&[], std::vec::Vec::as_slice)
397    }
398
399    /// Every concept whose `type` matches exactly.
400    pub fn concepts_of_type<'a>(&'a self, type_: &'a str) -> impl Iterator<Item = &'a Concept> {
401        self.concepts
402            .iter()
403            .filter(move |c| c.type_().as_deref() == Some(type_))
404    }
405
406    /// Every `Attested Computation` concept in the bundle.
407    ///
408    /// This is the discovery path: a consumer reaches a
409    /// computation by type, or by following a link from a concept that uses it.
410    pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
411        self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
412    }
413
414    /// A tag index synthesized by scanning frontmatter, tag to concept ids.
415    ///
416    /// OKF does not specify a file format for aggregating documents by
417    /// tag, so "a consumer that wants a tag-browsing view can synthesize one at
418    /// consumption time." This is that view.
419    #[must_use]
420    pub fn tags(&self) -> BTreeMap<String, Vec<ConceptId>> {
421        let mut out: BTreeMap<String, Vec<ConceptId>> = BTreeMap::new();
422        for c in &self.concepts {
423            for tag in c.document.frontmatter.tags() {
424                out.entry(tag).or_default().push(c.id.clone());
425            }
426        }
427        out
428    }
429
430    /// Every concept that is stale at `now`: `now >= stale_after`.
431    #[must_use]
432    pub fn stale_at(&self, now: DateTime) -> Vec<&Concept> {
433        self.concepts
434            .iter()
435            .filter(|c| c.is_stale_at(now))
436            .collect()
437    }
438
439    /// Every concept that is stale on `today`: `today >= stale_after`.
440    #[must_use]
441    pub fn stale_on(&self, today: Date) -> Vec<&Concept> {
442        self.concepts
443            .iter()
444            .filter(|c| c.is_stale_on(today))
445            .collect()
446    }
447
448    /// Resolves a path-valued frontmatter field to a file inside the bundle.
449    ///
450    /// Returns the first candidate from [`links::field_path_candidates`] that
451    /// actually exists on disk, or `None` for a URL, a scope descriptor, or a
452    /// path that names nothing. Unlike concept links, these fields routinely
453    /// point at non-markdown files such as `references/attesters/revenue.py`.
454    #[must_use]
455    pub fn resolve_path_field(&self, from: &ConceptId, raw: &str) -> Option<PathBuf> {
456        links::field_path_candidates(raw, from)
457            .into_iter()
458            .map(|rel| self.root.join(rel))
459            .find(|p| p.is_file())
460    }
461}
462
463/// The per-file result of loading a single markdown path.
464enum FileOutcome {
465    Index(PathBuf),
466    Log(PathBuf),
467    Concept(Concept),
468    Error(PathBuf, DocumentError),
469}
470
471/// Parses `md_files` in parallel, returning one [`FileOutcome`] per file in the
472/// input (sorted) order. I/O failures are fatal and surface as the first
473/// [`BundleError`] encountered, matching the sequential loader's `?` semantics.
474///
475/// Small bundles run inline to avoid thread-spawn overhead; larger ones split
476/// the list across one chunk per available core via [`std::thread::scope`].
477fn parse_files_parallel(
478    root: &Path,
479    md_files: &[PathBuf],
480) -> Result<Vec<FileOutcome>, BundleError> {
481    // Below this threshold, spawning threads costs more than it saves. The
482    // number is conservative: parsing a handful of small markdown files takes
483    // microseconds.
484    const PARALLEL_THRESHOLD: usize = 8;
485
486    if md_files.len() <= PARALLEL_THRESHOLD {
487        return md_files
488            .iter()
489            .map(|p| parse_one(root, p).map_err(BundleError::from))
490            .collect();
491    }
492
493    let n_threads = std::thread::available_parallelism()
494        .map_or(1, usize::from)
495        .min(md_files.len());
496    // Each thread owns a contiguous slice so the merged output preserves the
497    // sorted input order without a re-sort.
498    let chunk_size = md_files.len().div_ceil(n_threads);
499    let chunks: Vec<&[PathBuf]> = md_files.chunks(chunk_size).collect();
500
501    let results = std::thread::scope(|scope| {
502        chunks
503            .iter()
504            .map(|chunk| scope.spawn(|| parse_chunk(root, chunk)))
505            .map(|h| h.join().expect("worker thread panicked"))
506            .collect::<Vec<Result<Vec<FileOutcome>, BundleError>>>()
507    });
508
509    // Surface the first I/O error in chunk order, matching the sequential
510    // loader's behavior of failing on the earliest error in sorted file order.
511    let mut merged = Vec::with_capacity(md_files.len());
512    for result in results {
513        for outcome in result? {
514            merged.push(outcome);
515        }
516    }
517    Ok(merged)
518}
519
520/// Parses one chunk of files on a single thread.
521fn parse_chunk(root: &Path, chunk: &[PathBuf]) -> Result<Vec<FileOutcome>, BundleError> {
522    chunk
523        .iter()
524        .map(|p| parse_one(root, p).map_err(BundleError::from))
525        .collect()
526}
527
528/// Loads and classifies a single markdown file. `fs::read_to_string` failures
529/// propagate as [`BundleError::Io`]; frontmatter and concept-id failures are
530/// collected as [`FileOutcome::Error`] for the permissive-load path.
531fn parse_one(root: &Path, path: &Path) -> Result<FileOutcome, std::io::Error> {
532    let filename = path
533        .file_name()
534        .map(|f| f.to_string_lossy().into_owned())
535        .unwrap_or_default();
536    match filename.as_str() {
537        "index.md" => Ok(FileOutcome::Index(path.to_path_buf())),
538        "log.md" => Ok(FileOutcome::Log(path.to_path_buf())),
539        _ => {
540            let text = fs::read_to_string(path)?;
541            let outcome = match Document::parse(&text) {
542                Ok(document) => match ConceptId::from_path(root, path) {
543                    Ok(id) => FileOutcome::Concept(Concept {
544                        id,
545                        path: path.to_path_buf(),
546                        document,
547                    }),
548                    Err(e) => FileOutcome::Error(path.to_path_buf(), e.into()),
549                },
550                Err(e) => FileOutcome::Error(path.to_path_buf(), e),
551            };
552            Ok(outcome)
553        }
554    }
555}
556
557/// Reads `okf_version` from the bundle-root `index.md` frontmatter, if
558/// the file exists and the key is present as a scalar (coercing numeric floats
559/// or ints to string). Returns `None` for a missing file, an unparseable
560/// `index.md`, or a non-scalar value.
561fn read_okf_version(root: &Path) -> Option<String> {
562    let text = fs::read_to_string(root.join("index.md")).ok()?;
563    let doc = Document::parse(&text).ok()?;
564    doc.frontmatter
565        .get("okf_version")
566        .and_then(Value::as_display_string)
567}
568
569/// Recursively collects `*.md` file paths under `dir`.
570fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BundleError> {
571    let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
572    entries.sort_by_key(std::fs::DirEntry::file_name);
573    for entry in entries {
574        let path = entry.path();
575        let file_type = entry.file_type()?;
576        if file_type.is_dir() {
577            collect_markdown(&path, out)?;
578        } else if file_type.is_file() && path.extension().is_some_and(|e| e == "md") {
579            out.push(path);
580        }
581    }
582    Ok(())
583}
584
585/// Builds the outbound link and backlink maps for all concepts.
586fn build_graph(
587    concepts: &[Concept],
588    index: &HashMap<ConceptId, usize>,
589) -> (
590    HashMap<ConceptId, Vec<ResolvedLink>>,
591    HashMap<ConceptId, Vec<ConceptId>>,
592) {
593    let mut outbound: HashMap<ConceptId, Vec<ResolvedLink>> = HashMap::new();
594    let mut backlinks: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
595
596    for c in concepts {
597        let mut resolved = Vec::new();
598        for link in c.document.links() {
599            // A percent-encoded target has two readings; take whichever
600            // names a concept that is really there, else the literal one so the
601            // link is still reported as broken rather than dropped.
602            let candidates = link.resolve_all(&c.id);
603            let target = candidates
604                .iter()
605                .find(|t| index.contains_key(*t))
606                .or_else(|| candidates.first())
607                .cloned();
608            if let Some(target) = target {
609                let exists = index.contains_key(&target);
610                if exists {
611                    let entry = backlinks.entry(target.clone()).or_default();
612                    if !entry.contains(&c.id) {
613                        entry.push(c.id.clone());
614                    }
615                }
616                resolved.push(ResolvedLink {
617                    target,
618                    exists,
619                    text: link.text,
620                    raw: link.target,
621                });
622            }
623        }
624        outbound.insert(c.id.clone(), resolved);
625    }
626
627    (outbound, backlinks)
628}
629
630/// Builds the derivation graph: every concept's `sources` entries, with the
631/// ones naming another concept in the bundle resolved to its id.
632fn build_derivation_graph(
633    concepts: &[Concept],
634    index: &HashMap<ConceptId, usize>,
635) -> (
636    HashMap<ConceptId, Vec<ResolvedSource>>,
637    HashMap<ConceptId, Vec<ConceptId>>,
638) {
639    let mut sources: HashMap<ConceptId, Vec<ResolvedSource>> = HashMap::new();
640    let mut derived_by: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
641
642    for c in concepts {
643        let entries: Vec<ResolvedSource> = c
644            .sources()
645            .into_iter()
646            .map(|source| {
647                let concept = source
648                    .resource
649                    .as_deref()
650                    .and_then(|raw| resolve_concept_reference(index, &c.id, raw))
651                    .filter(|target| target != &c.id);
652                if let Some(target) = &concept {
653                    let entry = derived_by.entry(target.clone()).or_default();
654                    if !entry.contains(&c.id) {
655                        entry.push(c.id.clone());
656                    }
657                }
658                ResolvedSource { source, concept }
659            })
660            .collect();
661        if !entries.is_empty() {
662            sources.insert(c.id.clone(), entries);
663        }
664    }
665
666    (sources, derived_by)
667}
668
669/// Resolves a raw path-valued reference to a concept that exists in the bundle.
670///
671/// Both spellings are tried, with and without the `.md` suffix, and both
672/// readings of a relative path. Requiring the target to exist keeps
673/// scope descriptors and external URLs from being mistaken for concepts.
674fn resolve_concept_reference(
675    index: &HashMap<ConceptId, usize>,
676    from: &ConceptId,
677    raw: &str,
678) -> Option<ConceptId> {
679    for candidate in links::field_path_candidates(raw, from) {
680        let ids = [
681            links::concept_id_for_path(&candidate),
682            ConceptId::parse(&candidate).ok(),
683        ];
684        for id in ids.into_iter().flatten() {
685            if index.contains_key(&id) {
686                return Some(id);
687            }
688        }
689    }
690    None
691}