use crate::computation::AttestedComputation;
use crate::concept_id::ConceptId;
use crate::date::Date;
use crate::document::Document;
use crate::error::{BundleError, DocumentError};
use crate::links;
use crate::provenance::Source;
use crate::trust::{Status, TrustTier};
use crate::yaml::Value;
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
pub const RESERVED_FILENAMES: [&str; 2] = ["index.md", "log.md"];
#[derive(Clone, Debug)]
pub struct Concept {
pub id: ConceptId,
pub path: PathBuf,
pub document: Document,
}
impl Concept {
#[must_use]
pub fn type_(&self) -> Option<Cow<'_, str>> {
self.document.frontmatter.type_()
}
#[must_use]
pub fn display_title(&self) -> String {
self.document
.frontmatter
.title()
.map_or_else(|| self.id.name().to_string(), std::borrow::Cow::into_owned)
}
#[must_use]
pub fn trust_tier(&self) -> TrustTier {
self.document.frontmatter.trust_tier()
}
#[must_use]
pub fn status(&self) -> Status {
self.document.frontmatter.status()
}
#[must_use]
pub fn is_stale_on(&self, today: Date) -> bool {
self.document.frontmatter.is_stale_on(today)
}
#[must_use]
pub fn sources(&self) -> Vec<Source> {
self.document.frontmatter.sources()
}
#[must_use]
pub fn attested_computation(&self) -> Option<AttestedComputation> {
self.document.attested_computation()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedLink {
pub target: ConceptId,
pub exists: bool,
pub text: String,
pub raw: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResolvedSource {
pub source: Source,
pub concept: Option<ConceptId>,
}
#[derive(Debug)]
pub struct Bundle {
root: PathBuf,
concepts: Vec<Concept>,
index: HashMap<ConceptId, usize>,
index_files: Vec<PathBuf>,
log_files: Vec<PathBuf>,
parse_errors: Vec<(PathBuf, DocumentError)>,
outbound: HashMap<ConceptId, Vec<ResolvedLink>>,
backlinks: HashMap<ConceptId, Vec<ConceptId>>,
sources: HashMap<ConceptId, Vec<ResolvedSource>>,
derived_by: HashMap<ConceptId, Vec<ConceptId>>,
okf_version: Option<String>,
}
impl Bundle {
pub fn load(root: impl AsRef<Path>) -> Result<Self, BundleError> {
let root = root.as_ref().to_path_buf();
if !root.is_dir() {
return Err(BundleError::NotADirectory(root));
}
let mut md_files = Vec::new();
collect_markdown(&root, &mut md_files)?;
md_files.sort();
let outcomes = parse_files_parallel(&root, &md_files)?;
let mut concepts = Vec::new();
let mut index_files = Vec::new();
let mut log_files = Vec::new();
let mut parse_errors = Vec::new();
for outcome in outcomes {
match outcome {
FileOutcome::Index(p) => index_files.push(p),
FileOutcome::Log(p) => log_files.push(p),
FileOutcome::Concept(c) => concepts.push(c),
FileOutcome::Error(p, e) => parse_errors.push((p, e)),
}
}
let mut index = HashMap::new();
for (i, c) in concepts.iter().enumerate() {
index.insert(c.id.clone(), i);
}
let (outbound, backlinks) = build_graph(&concepts, &index);
let (sources, derived_by) = build_derivation_graph(&concepts, &index);
let okf_version = read_okf_version(&root);
Ok(Self {
root,
concepts,
index,
index_files,
log_files,
parse_errors,
outbound,
backlinks,
sources,
derived_by,
okf_version,
})
}
#[must_use]
pub fn root(&self) -> &Path {
&self.root
}
#[must_use]
pub fn concepts(&self) -> &[Concept] {
&self.concepts
}
#[must_use]
pub const fn len(&self) -> usize {
self.concepts.len()
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.concepts.is_empty()
}
#[must_use]
pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
self.index.get(id).map(|&i| &self.concepts[i])
}
#[must_use]
pub fn contains(&self, id: &ConceptId) -> bool {
self.index.contains_key(id)
}
#[must_use]
pub fn index_files(&self) -> &[PathBuf] {
&self.index_files
}
#[must_use]
pub fn log_files(&self) -> &[PathBuf] {
&self.log_files
}
#[must_use]
pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
&self.parse_errors
}
#[must_use]
pub fn links_from(&self, id: &ConceptId) -> &[ResolvedLink] {
self.outbound.get(id).map_or(&[], std::vec::Vec::as_slice)
}
#[must_use]
pub fn backlinks(&self, id: &ConceptId) -> &[ConceptId] {
self.backlinks.get(id).map_or(&[], std::vec::Vec::as_slice)
}
#[must_use]
pub fn broken_links(&self) -> Vec<(ConceptId, String)> {
let mut out = Vec::new();
for c in &self.concepts {
for link in self.links_from(&c.id) {
if !link.exists {
out.push((c.id.clone(), link.raw.clone()));
}
}
}
out
}
#[must_use]
pub fn okf_version(&self) -> Option<&str> {
self.okf_version.as_deref()
}
#[must_use]
pub fn sources_of(&self, id: &ConceptId) -> &[ResolvedSource] {
self.sources.get(id).map_or(&[], std::vec::Vec::as_slice)
}
#[must_use]
pub fn derived_from(&self, id: &ConceptId) -> Vec<&ConceptId> {
self.sources_of(id)
.iter()
.filter_map(|s| s.concept.as_ref())
.collect()
}
#[must_use]
pub fn derives(&self, id: &ConceptId) -> &[ConceptId] {
self.derived_by.get(id).map_or(&[], std::vec::Vec::as_slice)
}
pub fn concepts_of_type<'a>(&'a self, type_: &'a str) -> impl Iterator<Item = &'a Concept> {
self.concepts
.iter()
.filter(move |c| c.type_().as_deref() == Some(type_))
}
pub fn attested_computations(&self) -> impl Iterator<Item = &Concept> {
self.concepts_of_type(crate::computation::ATTESTED_COMPUTATION_TYPE)
}
#[must_use]
pub fn tags(&self) -> BTreeMap<String, Vec<ConceptId>> {
let mut out: BTreeMap<String, Vec<ConceptId>> = BTreeMap::new();
for c in &self.concepts {
for tag in c.document.frontmatter.tags() {
out.entry(tag).or_default().push(c.id.clone());
}
}
out
}
#[must_use]
pub fn stale_on(&self, today: Date) -> Vec<&Concept> {
self.concepts
.iter()
.filter(|c| c.is_stale_on(today))
.collect()
}
#[must_use]
pub fn resolve_path_field(&self, from: &ConceptId, raw: &str) -> Option<PathBuf> {
links::field_path_candidates(raw, from)
.into_iter()
.map(|rel| self.root.join(rel))
.find(|p| p.exists())
}
}
enum FileOutcome {
Index(PathBuf),
Log(PathBuf),
Concept(Concept),
Error(PathBuf, DocumentError),
}
fn parse_files_parallel(
root: &Path,
md_files: &[PathBuf],
) -> Result<Vec<FileOutcome>, BundleError> {
const PARALLEL_THRESHOLD: usize = 8;
if md_files.len() <= PARALLEL_THRESHOLD {
return md_files
.iter()
.map(|p| parse_one(root, p).map_err(BundleError::from))
.collect();
}
let n_threads = std::thread::available_parallelism()
.map_or(1, usize::from)
.min(md_files.len());
let chunk_size = md_files.len().div_ceil(n_threads);
let chunks: Vec<&[PathBuf]> = md_files.chunks(chunk_size).collect();
let results = std::thread::scope(|scope| {
chunks
.iter()
.map(|chunk| scope.spawn(|| parse_chunk(root, chunk)))
.map(|h| h.join().expect("worker thread panicked"))
.collect::<Vec<Result<Vec<FileOutcome>, BundleError>>>()
});
let mut merged = Vec::with_capacity(md_files.len());
for result in results {
for outcome in result? {
merged.push(outcome);
}
}
Ok(merged)
}
fn parse_chunk(root: &Path, chunk: &[PathBuf]) -> Result<Vec<FileOutcome>, BundleError> {
chunk
.iter()
.map(|p| parse_one(root, p).map_err(BundleError::from))
.collect()
}
fn parse_one(root: &Path, path: &Path) -> Result<FileOutcome, std::io::Error> {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_default();
match filename.as_str() {
"index.md" => Ok(FileOutcome::Index(path.to_path_buf())),
"log.md" => Ok(FileOutcome::Log(path.to_path_buf())),
_ => {
let text = fs::read_to_string(path)?;
let outcome = match Document::parse(&text) {
Ok(document) => match ConceptId::from_path(root, path) {
Ok(id) => FileOutcome::Concept(Concept {
id,
path: path.to_path_buf(),
document,
}),
Err(e) => FileOutcome::Error(path.to_path_buf(), e.into()),
},
Err(e) => FileOutcome::Error(path.to_path_buf(), e),
};
Ok(outcome)
}
}
}
fn read_okf_version(root: &Path) -> Option<String> {
let text = fs::read_to_string(root.join("index.md")).ok()?;
let doc = Document::parse(&text).ok()?;
doc.frontmatter
.get("okf_version")
.and_then(Value::as_str)
.map(str::to_owned)
}
fn collect_markdown(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), BundleError> {
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(std::fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
collect_markdown(&path, out)?;
} else if file_type.is_file() && path.extension().is_some_and(|e| e == "md") {
out.push(path);
}
}
Ok(())
}
fn build_graph(
concepts: &[Concept],
index: &HashMap<ConceptId, usize>,
) -> (
HashMap<ConceptId, Vec<ResolvedLink>>,
HashMap<ConceptId, Vec<ConceptId>>,
) {
let mut outbound: HashMap<ConceptId, Vec<ResolvedLink>> = HashMap::new();
let mut backlinks: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
for c in concepts {
let mut resolved = Vec::new();
for link in c.document.links() {
let candidates = link.resolve_all(&c.id);
let target = candidates
.iter()
.find(|t| index.contains_key(*t))
.or_else(|| candidates.first())
.cloned();
if let Some(target) = target {
let exists = index.contains_key(&target);
if exists {
let entry = backlinks.entry(target.clone()).or_default();
if !entry.contains(&c.id) {
entry.push(c.id.clone());
}
}
resolved.push(ResolvedLink {
target,
exists,
text: link.text,
raw: link.target,
});
}
}
outbound.insert(c.id.clone(), resolved);
}
(outbound, backlinks)
}
fn build_derivation_graph(
concepts: &[Concept],
index: &HashMap<ConceptId, usize>,
) -> (
HashMap<ConceptId, Vec<ResolvedSource>>,
HashMap<ConceptId, Vec<ConceptId>>,
) {
let mut sources: HashMap<ConceptId, Vec<ResolvedSource>> = HashMap::new();
let mut derived_by: HashMap<ConceptId, Vec<ConceptId>> = HashMap::new();
for c in concepts {
let entries: Vec<ResolvedSource> = c
.sources()
.into_iter()
.map(|source| {
let concept = source
.resource
.as_deref()
.and_then(|raw| resolve_concept_reference(index, &c.id, raw))
.filter(|target| target != &c.id);
if let Some(target) = &concept {
let entry = derived_by.entry(target.clone()).or_default();
if !entry.contains(&c.id) {
entry.push(c.id.clone());
}
}
ResolvedSource { source, concept }
})
.collect();
if !entries.is_empty() {
sources.insert(c.id.clone(), entries);
}
}
(sources, derived_by)
}
fn resolve_concept_reference(
index: &HashMap<ConceptId, usize>,
from: &ConceptId,
raw: &str,
) -> Option<ConceptId> {
for candidate in links::field_path_candidates(raw, from) {
let ids = [
links::concept_id_for_path(&candidate),
ConceptId::parse(&candidate).ok(),
];
for id in ids.into_iter().flatten() {
if index.contains_key(&id) {
return Some(id);
}
}
}
None
}