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    /// The bundle's root directory.
213    #[must_use]
214    pub fn root(&self) -> &Path {
215        &self.root
216    }
217
218    /// All successfully parsed concepts, in path order.
219    #[must_use]
220    pub fn concepts(&self) -> &[Concept] {
221        &self.concepts
222    }
223
224    /// Number of concepts.
225    #[must_use]
226    pub const fn len(&self) -> usize {
227        self.concepts.len()
228    }
229
230    /// `true` if the bundle has no concepts.
231    #[must_use]
232    pub const fn is_empty(&self) -> bool {
233        self.concepts.is_empty()
234    }
235
236    /// Looks up a concept by id.
237    #[must_use]
238    pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
239        self.index.get(id).map(|&i| &self.concepts[i])
240    }
241
242    /// `true` if a concept with this id exists.
243    #[must_use]
244    pub fn contains(&self, id: &ConceptId) -> bool {
245        self.index.contains_key(id)
246    }
247
248    /// Paths of all `index.md` files found.
249    #[must_use]
250    pub fn index_files(&self) -> &[PathBuf] {
251        &self.index_files
252    }
253
254    /// Paths of all `log.md` files found.
255    #[must_use]
256    pub fn log_files(&self) -> &[PathBuf] {
257        &self.log_files
258    }
259
260    /// Files whose frontmatter could not be parsed during loading.
261    #[must_use]
262    pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
263        &self.parse_errors
264    }
265
266    /// The resolved outbound cross-links from a concept.
267    #[must_use]
268    pub fn links_from(&self, id: &ConceptId) -> &[ResolvedLink] {
269        self.outbound.get(id).map_or(&[], std::vec::Vec::as_slice)
270    }
271
272    /// The ids of concepts that link to the given concept ("cited by" /
273    /// backlinks).
274    #[must_use]
275    pub fn backlinks(&self, id: &ConceptId) -> &[ConceptId] {
276        self.backlinks.get(id).map_or(&[], std::vec::Vec::as_slice)
277    }
278
279    /// All broken internal links in the bundle, as `(source, raw_target)`
280    /// pairs. Broken links are permitted by the spec, so this is
281    /// informational.
282    #[must_use]
283    pub fn broken_links(&self) -> Vec<(ConceptId, String)> {
284        let mut out = Vec::new();
285        for c in &self.concepts {
286            for link in self.links_from(&c.id) {
287                if !link.exists {
288                    out.push((c.id.clone(), link.raw.clone()));
289                }
290            }
291        }
292        out
293    }
294
295    /// The declared OKF version from the bundle-root `index.md` frontmatter, if
296    /// present (`okf_version`). This is the only place frontmatter is
297    /// permitted in an `index.md`.
298    ///
299    /// Cached at load time, so this is cheap to call repeatedly. A consumer
300    /// that does not understand the declared version SHOULD attempt best-effort
301    /// consumption rather than refusing the bundle, so this is reported, never
302    /// enforced.
303    ///
304    /// Returns `None` whether the root `index.md` is absent, unreadable, or
305    /// lacks the key; a malformed root `index.md` is reported separately by
306    /// `validate_bundle` (in the okf-validator crate).
307    #[must_use]
308    pub fn okf_version(&self) -> Option<&str> {
309        self.okf_version.as_deref()
310    }
311
312    /// The concept's `sources` entries, each resolved against the bundle.
313    #[must_use]
314    pub fn sources_of(&self, id: &ConceptId) -> &[ResolvedSource] {
315        self.sources.get(id).map_or(&[], std::vec::Vec::as_slice)
316    }
317
318    /// The concepts this one derives from: the `sources[].resource` entries
319    /// that name another concept in this bundle.
320    ///
321    /// Following these recursively is how a consumer lets credibility
322    /// propagate; external leaf sources carry only their intrinsic signals.
323    #[must_use]
324    pub fn derived_from(&self, id: &ConceptId) -> Vec<&ConceptId> {
325        self.sources_of(id)
326            .iter()
327            .filter_map(|s| s.concept.as_ref())
328            .collect()
329    }
330
331    /// The reverse of [`Bundle::derived_from`]: concepts that cite this one as
332    /// a source.
333    #[must_use]
334    pub fn derives(&self, id: &ConceptId) -> &[ConceptId] {
335        self.derived_by.get(id).map_or(&[], std::vec::Vec::as_slice)
336    }
337
338    /// Every concept whose `type` matches exactly.
339    pub fn concepts_of_type<'a>(&'a self, type_: &'a str) -> impl Iterator<Item = &'a Concept> {
340        self.concepts
341            .iter()
342            .filter(move |c| c.type_().as_deref() == Some(type_))
343    }
344
345    /// Every `Attested Computation` concept in the bundle.
346    ///
347    /// This is the discovery path: a consumer reaches a
348    /// computation by type, or by following a link from a concept that uses it.
349    pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
350        self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
351    }
352
353    /// A tag index synthesized by scanning frontmatter, tag to concept ids.
354    ///
355    /// OKF does not specify a file format for aggregating documents by
356    /// tag, so "a consumer that wants a tag-browsing view can synthesize one at
357    /// consumption time." This is that view.
358    #[must_use]
359    pub fn tags(&self) -> BTreeMap<String, Vec<ConceptId>> {
360        let mut out: BTreeMap<String, Vec<ConceptId>> = BTreeMap::new();
361        for c in &self.concepts {
362            for tag in c.document.frontmatter.tags() {
363                out.entry(tag).or_default().push(c.id.clone());
364            }
365        }
366        out
367    }
368
369    /// Every concept that is stale at `now`: `now >= stale_after`.
370    #[must_use]
371    pub fn stale_at(&self, now: DateTime) -> Vec<&Concept> {
372        self.concepts
373            .iter()
374            .filter(|c| c.is_stale_at(now))
375            .collect()
376    }
377
378    /// Every concept that is stale on `today`: `today >= stale_after`.
379    #[must_use]
380    pub fn stale_on(&self, today: Date) -> Vec<&Concept> {
381        self.concepts
382            .iter()
383            .filter(|c| c.is_stale_on(today))
384            .collect()
385    }
386
387    /// Resolves a path-valued frontmatter field to a file inside the bundle.
388    ///
389    /// Returns the first candidate from [`links::field_path_candidates`] that
390    /// actually exists on disk, or `None` for a URL, a scope descriptor, or a
391    /// path that names nothing. Unlike concept links, these fields routinely
392    /// point at non-markdown files such as `references/attesters/revenue.py`.
393    #[must_use]
394    pub fn resolve_path_field(&self, from: &ConceptId, raw: &str) -> Option<PathBuf> {
395        links::field_path_candidates(raw, from)
396            .into_iter()
397            .map(|rel| self.root.join(rel))
398            .find(|p| p.is_file())
399    }
400}
401
402/// The per-file result of loading a single markdown path.
403enum FileOutcome {
404    Index(PathBuf),
405    Log(PathBuf),
406    Concept(Concept),
407    Error(PathBuf, DocumentError),
408}
409
410/// Parses `md_files` in parallel, returning one [`FileOutcome`] per file in the
411/// input (sorted) order. I/O failures are fatal and surface as the first
412/// [`BundleError`] encountered, matching the sequential loader's `?` semantics.
413///
414/// Small bundles run inline to avoid thread-spawn overhead; larger ones split
415/// the list across one chunk per available core via [`std::thread::scope`].
416fn parse_files_parallel(
417    root: &Path,
418    md_files: &[PathBuf],
419) -> Result<Vec<FileOutcome>, BundleError> {
420    // Below this threshold, spawning threads costs more than it saves. The
421    // number is conservative: parsing a handful of small markdown files takes
422    // microseconds.
423    const PARALLEL_THRESHOLD: usize = 8;
424
425    if md_files.len() <= PARALLEL_THRESHOLD {
426        return md_files
427            .iter()
428            .map(|p| parse_one(root, p).map_err(BundleError::from))
429            .collect();
430    }
431
432    let n_threads = std::thread::available_parallelism()
433        .map_or(1, usize::from)
434        .min(md_files.len());
435    // Each thread owns a contiguous slice so the merged output preserves the
436    // sorted input order without a re-sort.
437    let chunk_size = md_files.len().div_ceil(n_threads);
438    let chunks: Vec<&[PathBuf]> = md_files.chunks(chunk_size).collect();
439
440    let results = std::thread::scope(|scope| {
441        chunks
442            .iter()
443            .map(|chunk| scope.spawn(|| parse_chunk(root, chunk)))
444            .map(|h| h.join().expect("worker thread panicked"))
445            .collect::<Vec<Result<Vec<FileOutcome>, BundleError>>>()
446    });
447
448    // Surface the first I/O error in chunk order, matching the sequential
449    // loader's behavior of failing on the earliest error in sorted file order.
450    let mut merged = Vec::with_capacity(md_files.len());
451    for result in results {
452        for outcome in result? {
453            merged.push(outcome);
454        }
455    }
456    Ok(merged)
457}
458
459/// Parses one chunk of files on a single thread.
460fn parse_chunk(root: &Path, chunk: &[PathBuf]) -> Result<Vec<FileOutcome>, BundleError> {
461    chunk
462        .iter()
463        .map(|p| parse_one(root, p).map_err(BundleError::from))
464        .collect()
465}
466
467/// Loads and classifies a single markdown file. `fs::read_to_string` failures
468/// propagate as [`BundleError::Io`]; frontmatter and concept-id failures are
469/// collected as [`FileOutcome::Error`] for the permissive-load path.
470fn parse_one(root: &Path, path: &Path) -> Result<FileOutcome, std::io::Error> {
471    let filename = path
472        .file_name()
473        .map(|f| f.to_string_lossy().into_owned())
474        .unwrap_or_default();
475    match filename.as_str() {
476        "index.md" => Ok(FileOutcome::Index(path.to_path_buf())),
477        "log.md" => Ok(FileOutcome::Log(path.to_path_buf())),
478        _ => {
479            let text = fs::read_to_string(path)?;
480            let outcome = match Document::parse(&text) {
481                Ok(document) => match ConceptId::from_path(root, path) {
482                    Ok(id) => FileOutcome::Concept(Concept {
483                        id,
484                        path: path.to_path_buf(),
485                        document,
486                    }),
487                    Err(e) => FileOutcome::Error(path.to_path_buf(), e.into()),
488                },
489                Err(e) => FileOutcome::Error(path.to_path_buf(), e),
490            };
491            Ok(outcome)
492        }
493    }
494}
495
496/// Reads `okf_version` from the bundle-root `index.md` frontmatter, if
497/// the file exists and the key is present as a string scalar. Returns `None`
498/// for a missing file, an unparseable `index.md`, or a non-string value.
499fn read_okf_version(root: &Path) -> Option<String> {
500    let text = fs::read_to_string(root.join("index.md")).ok()?;
501    let doc = Document::parse(&text).ok()?;
502    doc.frontmatter
503        .get("okf_version")
504        .and_then(Value::as_str)
505        .map(str::to_owned)
506}
507
508/// Recursively collects `*.md` file paths under `dir`.
509fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BundleError> {
510    let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
511    entries.sort_by_key(std::fs::DirEntry::file_name);
512    for entry in entries {
513        let path = entry.path();
514        let file_type = entry.file_type()?;
515        if file_type.is_dir() {
516            collect_markdown(&path, out)?;
517        } else if file_type.is_file() && path.extension().is_some_and(|e| e == "md") {
518            out.push(path);
519        }
520    }
521    Ok(())
522}
523
524/// Builds the outbound link and backlink maps for all concepts.
525fn build_graph(
526    concepts: &[Concept],
527    index: &HashMap<ConceptId, usize>,
528) -> (
529    HashMap<ConceptId, Vec<ResolvedLink>>,
530    HashMap<ConceptId, Vec<ConceptId>>,
531) {
532    let mut outbound: HashMap<ConceptId, Vec<ResolvedLink>> = HashMap::new();
533    let mut backlinks: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
534
535    for c in concepts {
536        let mut resolved = Vec::new();
537        for link in c.document.links() {
538            // A percent-encoded target has two readings; take whichever
539            // names a concept that is really there, else the literal one so the
540            // link is still reported as broken rather than dropped.
541            let candidates = link.resolve_all(&c.id);
542            let target = candidates
543                .iter()
544                .find(|t| index.contains_key(*t))
545                .or_else(|| candidates.first())
546                .cloned();
547            if let Some(target) = target {
548                let exists = index.contains_key(&target);
549                if exists {
550                    let entry = backlinks.entry(target.clone()).or_default();
551                    if !entry.contains(&c.id) {
552                        entry.push(c.id.clone());
553                    }
554                }
555                resolved.push(ResolvedLink {
556                    target,
557                    exists,
558                    text: link.text,
559                    raw: link.target,
560                });
561            }
562        }
563        outbound.insert(c.id.clone(), resolved);
564    }
565
566    (outbound, backlinks)
567}
568
569/// Builds the derivation graph: every concept's `sources` entries, with the
570/// ones naming another concept in the bundle resolved to its id.
571fn build_derivation_graph(
572    concepts: &[Concept],
573    index: &HashMap<ConceptId, usize>,
574) -> (
575    HashMap<ConceptId, Vec<ResolvedSource>>,
576    HashMap<ConceptId, Vec<ConceptId>>,
577) {
578    let mut sources: HashMap<ConceptId, Vec<ResolvedSource>> = HashMap::new();
579    let mut derived_by: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
580
581    for c in concepts {
582        let entries: Vec<ResolvedSource> = c
583            .sources()
584            .into_iter()
585            .map(|source| {
586                let concept = source
587                    .resource
588                    .as_deref()
589                    .and_then(|raw| resolve_concept_reference(index, &c.id, raw))
590                    .filter(|target| target != &c.id);
591                if let Some(target) = &concept {
592                    let entry = derived_by.entry(target.clone()).or_default();
593                    if !entry.contains(&c.id) {
594                        entry.push(c.id.clone());
595                    }
596                }
597                ResolvedSource { source, concept }
598            })
599            .collect();
600        if !entries.is_empty() {
601            sources.insert(c.id.clone(), entries);
602        }
603    }
604
605    (sources, derived_by)
606}
607
608/// Resolves a raw path-valued reference to a concept that exists in the bundle.
609///
610/// Both spellings are tried, with and without the `.md` suffix, and both
611/// readings of a relative path. Requiring the target to exist keeps
612/// scope descriptors and external URLs from being mistaken for concepts.
613fn resolve_concept_reference(
614    index: &HashMap<ConceptId, usize>,
615    from: &ConceptId,
616    raw: &str,
617) -> Option<ConceptId> {
618    for candidate in links::field_path_candidates(raw, from) {
619        let ids = [
620            links::concept_id_for_path(&candidate),
621            ConceptId::parse(&candidate).ok(),
622        ];
623        for id in ids.into_iter().flatten() {
624            if index.contains_key(&id) {
625                return Some(id);
626            }
627        }
628    }
629    None
630}