1use 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
32pub const RESERVED_FILENAMES: [&str; 2] = ["index.md", "log.md"];
34
35#[derive(Clone, Debug)]
37pub struct Concept {
38 pub id: ConceptId,
40 pub path: PathBuf,
42 pub document: Document,
44}
45
46impl Concept {
47 #[must_use]
49 pub fn type_(&self) -> Option<Cow<'_, str>> {
50 self.document.frontmatter.type_()
51 }
52
53 #[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 #[must_use]
65 pub fn trust_tier(&self) -> TrustTier {
66 self.document.frontmatter.trust_tier()
67 }
68
69 #[must_use]
71 pub fn status(&self) -> Status {
72 self.document.frontmatter.status()
73 }
74
75 #[must_use]
77 pub fn is_stale_at(&self, now: DateTime) -> bool {
78 self.document.frontmatter.is_stale_at(now)
79 }
80
81 #[must_use]
83 pub fn is_stale_on(&self, today: Date) -> bool {
84 self.document.frontmatter.is_stale_on(today)
85 }
86
87 #[must_use]
89 pub fn sources(&self) -> Vec<Source> {
90 self.document.frontmatter.sources()
91 }
92
93 #[must_use]
95 pub fn attested_computation(&self) -> Option<AttestedComputation> {
96 self.document.attested_computation()
97 }
98}
99
100#[derive(Clone, Debug, PartialEq, Eq)]
102pub struct ResolvedLink {
103 pub target: ConceptId,
105 pub exists: bool,
108 pub text: String,
110 pub raw: String,
112}
113
114#[derive(Clone, Debug, PartialEq, Eq)]
116pub struct ResolvedSource {
117 pub source: Source,
119 pub concept: Option<ConceptId>,
122}
123
124#[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 okf_version: Option<String>,
141}
142
143impl Bundle {
144 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 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 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 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 #[must_use]
275 pub fn root(&self) -> &Path {
276 &self.root
277 }
278
279 #[must_use]
281 pub fn concepts(&self) -> &[Concept] {
282 &self.concepts
283 }
284
285 #[must_use]
287 pub const fn len(&self) -> usize {
288 self.concepts.len()
289 }
290
291 #[must_use]
293 pub const fn is_empty(&self) -> bool {
294 self.concepts.is_empty()
295 }
296
297 #[must_use]
299 pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
300 self.index.get(id).map(|&i| &self.concepts[i])
301 }
302
303 #[must_use]
305 pub fn contains(&self, id: &ConceptId) -> bool {
306 self.index.contains_key(id)
307 }
308
309 #[must_use]
311 pub fn index_files(&self) -> &[PathBuf] {
312 &self.index_files
313 }
314
315 #[must_use]
317 pub fn log_files(&self) -> &[PathBuf] {
318 &self.log_files
319 }
320
321 #[must_use]
323 pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
324 &self.parse_errors
325 }
326
327 #[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 #[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 #[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 #[must_use]
369 pub fn okf_version(&self) -> Option<&str> {
370 self.okf_version.as_deref()
371 }
372
373 #[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 #[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 #[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 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 pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
411 self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
412 }
413
414 #[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 #[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 #[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 #[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
463enum FileOutcome {
465 Index(PathBuf),
466 Log(PathBuf),
467 Concept(Concept),
468 Error(PathBuf, DocumentError),
469}
470
471fn parse_files_parallel(
478 root: &Path,
479 md_files: &[PathBuf],
480) -> Result<Vec<FileOutcome>, BundleError> {
481 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 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 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
520fn 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
528fn 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
557fn 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
569fn 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
585fn 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 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
630fn 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
669fn 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}