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