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)?;
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 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 #[must_use]
215 pub fn root(&self) -> &Path {
216 &self.root
217 }
218
219 #[must_use]
221 pub fn concepts(&self) -> &[Concept] {
222 &self.concepts
223 }
224
225 #[must_use]
227 pub const fn len(&self) -> usize {
228 self.concepts.len()
229 }
230
231 #[must_use]
233 pub const fn is_empty(&self) -> bool {
234 self.concepts.is_empty()
235 }
236
237 #[must_use]
239 pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
240 self.index.get(id).map(|&i| &self.concepts[i])
241 }
242
243 #[must_use]
245 pub fn contains(&self, id: &ConceptId) -> bool {
246 self.index.contains_key(id)
247 }
248
249 #[must_use]
251 pub fn index_files(&self) -> &[PathBuf] {
252 &self.index_files
253 }
254
255 #[must_use]
257 pub fn log_files(&self) -> &[PathBuf] {
258 &self.log_files
259 }
260
261 #[must_use]
263 pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
264 &self.parse_errors
265 }
266
267 #[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 #[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 #[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 #[must_use]
309 pub fn okf_version(&self) -> Option<&str> {
310 self.okf_version.as_deref()
311 }
312
313 #[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 #[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 #[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 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 pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
351 self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
352 }
353
354 #[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 #[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 #[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 #[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
403enum FileOutcome {
405 Index(PathBuf),
406 Log(PathBuf),
407 Concept(Concept),
408 Error(PathBuf, DocumentError),
409}
410
411fn parse_files_parallel(
418 root: &Path,
419 md_files: &[PathBuf],
420) -> Result<Vec<FileOutcome>, BundleError> {
421 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 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 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
460fn 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
468fn 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
497fn 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
509fn 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
525fn 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 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
570fn 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
609fn 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}