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 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 {
pub fn type_(&self) -> Option<String> {
self.document.frontmatter.type_()
}
pub fn display_title(&self) -> String {
self.document
.frontmatter
.title()
.unwrap_or_else(|| self.id.name().to_string())
}
pub fn trust_tier(&self) -> TrustTier {
self.document.frontmatter.trust_tier()
}
pub fn status(&self) -> Status {
self.document.frontmatter.status()
}
pub fn is_stale_on(&self, today: Date) -> bool {
self.document.frontmatter.is_stale_on(today)
}
pub fn sources(&self) -> Vec<Source> {
self.document.frontmatter.sources()
}
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>>,
}
impl Bundle {
pub fn load(root: impl AsRef<Path>) -> Result<Bundle, 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 mut concepts = Vec::new();
let mut index_files = Vec::new();
let mut log_files = Vec::new();
let mut parse_errors = Vec::new();
for path in md_files {
let filename = path
.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default();
match filename.as_str() {
"index.md" => index_files.push(path),
"log.md" => log_files.push(path),
_ => {
let text = fs::read_to_string(&path)?;
match Document::parse(&text) {
Ok(document) => match ConceptId::from_path(&root, &path) {
Ok(id) => concepts.push(Concept { id, path, document }),
Err(e) => parse_errors
.push((path, DocumentError::MissingKeys(vec![e.to_string()]))),
},
Err(e) => parse_errors.push((path, 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);
Ok(Bundle {
root,
concepts,
index,
index_files,
log_files,
parse_errors,
outbound,
backlinks,
sources,
derived_by,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn concepts(&self) -> &[Concept] {
&self.concepts
}
pub fn len(&self) -> usize {
self.concepts.len()
}
pub fn is_empty(&self) -> bool {
self.concepts.is_empty()
}
pub fn get(&self, id: &ConceptId) -> Option<&Concept> {
self.index.get(id).map(|&i| &self.concepts[i])
}
pub fn contains(&self, id: &ConceptId) -> bool {
self.index.contains_key(id)
}
pub fn index_files(&self) -> &[PathBuf] {
&self.index_files
}
pub fn log_files(&self) -> &[PathBuf] {
&self.log_files
}
pub fn parse_errors(&self) -> &[(PathBuf, DocumentError)] {
&self.parse_errors
}
pub fn links_from(&self, id: &ConceptId) -> &[ResolvedLink] {
self.outbound.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn backlinks(&self, id: &ConceptId) -> &[ConceptId] {
self.backlinks.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
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
}
pub fn okf_version(&self) -> Option<String> {
let root_index = self.root.join("index.md");
let text = fs::read_to_string(&root_index).ok()?;
let doc = Document::parse(&text).ok()?;
doc.frontmatter
.get("okf_version")
.and_then(crate::yaml::Value::as_display_string)
}
pub fn sources_of(&self, id: &ConceptId) -> &[ResolvedSource] {
self.sources.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
pub fn derived_from(&self, id: &ConceptId) -> Vec<&ConceptId> {
self.sources_of(id)
.iter()
.filter_map(|s| s.concept.as_ref())
.collect()
}
pub fn derives(&self, id: &ConceptId) -> &[ConceptId] {
self.derived_by.get(id).map(|v| v.as_slice()).unwrap_or(&[])
}
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)
}
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
}
pub fn stale_on(&self, today: Date) -> Vec<&Concept> {
self.concepts
.iter()
.filter(|c| c.is_stale_on(today))
.collect()
}
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())
}
}
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(|e| e.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().map(|e| e == "md").unwrap_or(false) {
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
}