Skip to main content

okf_core/
bundle.rs

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