1use 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
33pub const RESERVED_FILENAMES: [&str; 2] = ["index.md", "log.md"];
35
36#[derive(Clone, Debug)]
38pub struct Concept {
39 pub id: ConceptId,
41 pub path: PathBuf,
43 pub document: Document,
45}
46
47impl Concept {
48 #[must_use]
50 pub fn type_(&self) -> Option<Cow<'_, str>> {
51 self.document.frontmatter.type_()
52 }
53
54 #[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 #[must_use]
66 pub fn trust_tier(&self) -> TrustTier {
67 self.document.frontmatter.trust_tier()
68 }
69
70 #[must_use]
72 pub fn status(&self) -> Status {
73 self.document.frontmatter.status()
74 }
75
76 #[must_use]
78 pub fn is_stale_at(&self, now: DateTime) -> bool {
79 self.document.frontmatter.is_stale_at(now)
80 }
81
82 #[must_use]
84 pub fn is_stale_on(&self, today: Date) -> bool {
85 self.document.frontmatter.is_stale_on(today)
86 }
87
88 #[must_use]
90 pub fn sources(&self) -> Vec<Source> {
91 self.document.frontmatter.sources()
92 }
93
94 #[must_use]
96 pub fn attested_computation(&self) -> Option<AttestedComputation> {
97 self.document.attested_computation()
98 }
99}
100
101#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct ResolvedLink {
104 pub target: ConceptId,
106 pub exists: bool,
109 pub text: String,
111 pub raw: String,
113}
114
115#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct ResolvedSource {
118 pub source: Source,
120 pub concept: Option<ConceptId>,
123}
124
125#[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 okf_version: Option<String>,
142}
143
144impl Bundle {
145 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 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 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 #[must_use]
216 pub fn root(&self) -> &Path {
217 &self.root
218 }
219
220 #[must_use]
222 pub fn concepts(&self) -> &[Concept] {
223 &self.concepts
224 }
225
226 #[must_use]
228 pub const fn len(&self) -> usize {
229 self.concepts.len()
230 }
231
232 #[must_use]
234 pub const fn is_empty(&self) -> bool {
235 self.concepts.is_empty()
236 }
237
238 #[must_use]
240 pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
241 self.index.get(id).map(|&i| &self.concepts[i])
242 }
243
244 #[must_use]
246 pub fn contains(&self, id: &ConceptId) -> bool {
247 self.index.contains_key(id)
248 }
249
250 #[must_use]
252 pub fn index_files(&self) -> &[PathBuf] {
253 &self.index_files
254 }
255
256 #[must_use]
258 pub fn log_files(&self) -> &[PathBuf] {
259 &self.log_files
260 }
261
262 #[must_use]
264 pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
265 &self.parse_errors
266 }
267
268 #[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 #[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 #[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 #[must_use]
310 pub fn okf_version(&self) -> Option<&str> {
311 self.okf_version.as_deref()
312 }
313
314 #[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 #[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 #[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 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 pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
352 self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
353 }
354
355 #[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 #[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 #[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 #[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
404enum FileOutcome {
406 Index(PathBuf),
407 Log(PathBuf),
408 Concept(Concept),
409 Error(PathBuf, DocumentError),
410}
411
412fn parse_files_parallel(
419 root: &Path,
420 md_files: &[PathBuf],
421) -> Result<Vec<FileOutcome>, BundleError> {
422 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 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 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
461fn 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
469fn 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
498fn 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
510fn 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
526fn 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 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
571fn 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
610fn 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}