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 #[must_use]
214 pub fn root(&self) -> &Path {
215 &self.root
216 }
217
218 #[must_use]
220 pub fn concepts(&self) -> &[Concept] {
221 &self.concepts
222 }
223
224 #[must_use]
226 pub const fn len(&self) -> usize {
227 self.concepts.len()
228 }
229
230 #[must_use]
232 pub const fn is_empty(&self) -> bool {
233 self.concepts.is_empty()
234 }
235
236 #[must_use]
238 pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
239 self.index.get(id).map(|&i| &self.concepts[i])
240 }
241
242 #[must_use]
244 pub fn contains(&self, id: &ConceptId) -> bool {
245 self.index.contains_key(id)
246 }
247
248 #[must_use]
250 pub fn index_files(&self) -> &[PathBuf] {
251 &self.index_files
252 }
253
254 #[must_use]
256 pub fn log_files(&self) -> &[PathBuf] {
257 &self.log_files
258 }
259
260 #[must_use]
262 pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
263 &self.parse_errors
264 }
265
266 #[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 #[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 #[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 #[must_use]
308 pub fn okf_version(&self) -> Option<&str> {
309 self.okf_version.as_deref()
310 }
311
312 #[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 #[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 #[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 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 pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
350 self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
351 }
352
353 #[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 #[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 #[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 #[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
402enum FileOutcome {
404 Index(PathBuf),
405 Log(PathBuf),
406 Concept(Concept),
407 Error(PathBuf, DocumentError),
408}
409
410fn parse_files_parallel(
417 root: &Path,
418 md_files: &[PathBuf],
419) -> Result<Vec<FileOutcome>, BundleError> {
420 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 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 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
459fn 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
467fn 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
496fn 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
508fn 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
524fn 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 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
569fn 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
608fn 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}