use std::path::Path;
use okf_core::{Bundle, TrustTier};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum InspectError {
#[error("`{path}` is not a readable OKF bundle: {detail}")]
Unreadable {
path: String,
detail: String,
},
#[error("`{given}` is not an ISO date (expected YYYY-MM-DD)")]
BadDate {
given: String,
},
#[error("cannot read the current date; pass --today YYYY-MM-DD")]
NoClock,
}
pub(super) fn load(root: &Path) -> Result<Bundle, InspectError> {
Bundle::load(root).map_err(|e| InspectError::Unreadable {
path: root.display().to_string(),
detail: e.to_string(),
})
}
#[derive(Debug, Clone, Serialize)]
pub struct ConceptTrust {
pub id: String,
pub tier: &'static str,
pub status: String,
pub verified_by: Vec<String>,
pub stale_after: Option<String>,
pub stale: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct TrustSummary {
pub root: String,
pub okf_version: Option<String>,
pub total: usize,
pub human_reviewed: usize,
pub machine_confirmed: usize,
pub unverified: usize,
pub stale: usize,
pub today: String,
pub concepts: Vec<ConceptTrust>,
}
pub fn trust_summary(root: &Path, today: Option<&str>) -> Result<TrustSummary, InspectError> {
let today = resolve_today(today)?;
Ok(summarise_trust(
&load(root)?,
&root.display().to_string(),
today,
))
}
fn resolve_today(given: Option<&str>) -> Result<okf_core::Date, InspectError> {
match given {
Some(raw) => okf_core::Date::parse(raw).ok_or_else(|| InspectError::BadDate {
given: raw.to_owned(),
}),
None => okf_core::Date::today_utc().ok_or(InspectError::NoClock),
}
}
#[must_use]
pub fn summarise_trust(bundle: &Bundle, root: &str, today: okf_core::Date) -> TrustSummary {
let mut summary = TrustSummary {
root: root.to_owned(),
okf_version: bundle.okf_version().map(ToOwned::to_owned),
total: bundle.concepts().len(),
human_reviewed: 0,
machine_confirmed: 0,
unverified: 0,
stale: 0,
today: today.to_string(),
concepts: Vec::with_capacity(bundle.concepts().len()),
};
for concept in bundle.concepts() {
let tier = concept.trust_tier();
match tier {
TrustTier::HumanReviewed => summary.human_reviewed += 1,
TrustTier::MachineConfirmed => summary.machine_confirmed += 1,
TrustTier::Unverified => summary.unverified += 1,
}
let stale = concept.is_stale_on(today);
if stale {
summary.stale += 1;
}
summary.concepts.push(ConceptTrust {
id: concept.id.to_string(),
tier: tier.as_str(),
status: concept.status().to_string(),
stale_after: concept
.document
.frontmatter
.stale_after()
.map(|d| d.to_string()),
stale,
verified_by: concept
.document
.frontmatter
.verified()
.into_iter()
.filter_map(|v| v.by.map(|by| by.as_str().to_owned()))
.collect(),
});
}
summary
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrokenLink {
pub from: String,
pub target: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LinkReport {
pub root: String,
pub concepts: usize,
pub links: usize,
pub broken: Vec<BrokenLink>,
}
impl LinkReport {
#[must_use]
pub const fn is_clean(&self) -> bool {
self.broken.is_empty()
}
}
pub fn link_report(root: &Path) -> Result<LinkReport, InspectError> {
let bundle = load(root)?;
let links = bundle
.concepts()
.iter()
.map(|c| bundle.links_from(&c.id).len())
.sum();
Ok(LinkReport {
root: root.display().to_string(),
concepts: bundle.concepts().len(),
links,
broken: bundle
.broken_links()
.into_iter()
.map(|(from, target)| BrokenLink {
from: from.to_string(),
target,
})
.collect(),
})
}
#[derive(Debug, Clone, Serialize)]
pub struct TrustMove {
pub id: String,
pub tier: Option<(String, String)>,
pub status: Option<(String, String)>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffReport {
pub before: String,
pub after: String,
pub added: Vec<String>,
pub removed: Vec<String>,
pub renamed: Vec<(String, String)>,
pub content_changed: Vec<String>,
pub frontmatter_changed: Vec<String>,
pub trust_changed: Vec<TrustMove>,
pub links_broken: Vec<(String, String)>,
pub links_mended: Vec<(String, String)>,
}
impl DiffReport {
#[must_use]
pub fn is_unchanged(&self) -> bool {
self.added.is_empty()
&& self.removed.is_empty()
&& self.renamed.is_empty()
&& self.content_changed.is_empty()
&& self.frontmatter_changed.is_empty()
&& self.trust_changed.is_empty()
&& self.links_broken.is_empty()
&& self.links_mended.is_empty()
}
}
pub fn diff_report(before: &Path, after: &Path) -> Result<DiffReport, InspectError> {
let a = load(before)?;
let b = load(after)?;
let d = okf_core::bundle_diff(&a, &b);
let ids = |v: Vec<okf_core::ConceptId>| v.iter().map(ToString::to_string).collect::<Vec<_>>();
let pairs = |v: Vec<(okf_core::ConceptId, String)>| {
v.into_iter()
.map(|(id, t)| (id.to_string(), t))
.collect::<Vec<_>>()
};
Ok(DiffReport {
before: before.display().to_string(),
after: after.display().to_string(),
added: ids(d.added),
removed: ids(d.removed),
renamed: d
.renamed
.into_iter()
.map(|r| (r.from.to_string(), r.to.to_string()))
.collect(),
content_changed: ids(d.content),
frontmatter_changed: d.frontmatter.iter().map(|c| c.id.to_string()).collect(),
trust_changed: d
.trust
.into_iter()
.map(|t| TrustMove {
id: t.id.to_string(),
tier: t
.tier
.map(|(a, b)| (a.as_str().to_owned(), b.as_str().to_owned())),
status: t.status.map(|(a, b)| (a.to_string(), b.to_string())),
})
.collect(),
links_broken: pairs(d.broken_links),
links_mended: pairs(d.mended_links),
})
}
#[derive(Debug, Clone, Serialize)]
pub struct SyntaxFinding {
pub concept: String,
pub path: String,
pub line: Option<usize>,
pub language: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SyntaxReport {
pub root: String,
pub scope: &'static str,
pub checked: usize,
pub skipped: usize,
pub languages: Vec<String>,
pub findings: Vec<SyntaxFinding>,
}
impl SyntaxReport {
#[must_use]
pub const fn passed(&self) -> bool {
self.findings.is_empty()
}
}
fn language_for_runtime(runtime: Option<&str>) -> Option<&'static str> {
match runtime.map(|r| r.trim().to_ascii_lowercase()).as_deref() {
Some("bigquery") => Some("sql"),
_ => None,
}
}
pub fn syntax_report(root: &Path, computations_only: bool) -> Result<SyntaxReport, InspectError> {
let bundle = load(root)?;
let languages = rto_okf_syntax::checkable_languages()
.into_iter()
.map(|l| l.as_str().to_owned())
.collect();
let mut report = SyntaxReport {
root: root.display().to_string(),
scope: if computations_only {
"computations"
} else {
"all-blocks"
},
checked: 0,
skipped: 0,
languages,
findings: Vec::new(),
};
for concept in bundle.concepts() {
let rel = concept
.path
.strip_prefix(bundle.root())
.unwrap_or(&concept.path)
.display()
.to_string();
if computations_only {
let Some(computation) = concept.attested_computation() else {
continue;
};
let okf_core::ComputationSource::Inline(inline) = &computation.computation else {
report.skipped += 1;
continue;
};
let tag = inline
.language
.as_deref()
.or_else(|| language_for_runtime(computation.runtime.as_deref()))
.unwrap_or("");
let line = computation_line(&concept.document.body, &inline.code);
record(
&mut report,
&concept.id.to_string(),
&rel,
line,
tag,
&inline.code,
);
} else {
for block in rto_okf_syntax::extract_fenced_code_blocks(&concept.document.body) {
let tag = block.language.as_deref().unwrap_or("");
record(
&mut report,
&concept.id.to_string(),
&rel,
Some(block.start_line),
tag,
&block.code,
);
}
}
}
Ok(report)
}
fn computation_line(body: &str, code: &str) -> Option<usize> {
let wanted = code.trim();
if let Some(block) = rto_okf_syntax::extract_fenced_code_blocks(body)
.into_iter()
.find(|b| b.code.trim() == wanted)
{
return Some(block.start_line);
}
body.lines().enumerate().find_map(|(i, l)| {
l.trim_start()
.strip_prefix('#')
.is_some_and(|rest| rest.trim().eq_ignore_ascii_case("computation"))
.then_some(i + 1)
})
}
fn record(
report: &mut SyntaxReport,
concept: &str,
path: &str,
line: Option<usize>,
tag: &str,
code: &str,
) {
let language = rto_okf_syntax::Language::from_tag(tag);
if !rto_okf_syntax::is_checkable(language) {
report.skipped += 1;
return;
}
report.checked += 1;
if let Err(err) = rto_okf_syntax::check_syntax(tag, code) {
report.findings.push(SyntaxFinding {
concept: concept.to_owned(),
path: path.to_owned(),
line,
language: err.language.clone(),
message: err.to_string(),
});
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ComputationEntry {
pub concept: String,
pub path: String,
pub runtime: Option<String>,
pub source: &'static str,
pub file: Option<String>,
pub language: Option<String>,
pub lines: Option<usize>,
pub parameters: Vec<String>,
pub has_executor: bool,
pub has_attester: bool,
pub redundant_inline: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct ComputationReport {
pub root: String,
pub concepts: usize,
pub computations: usize,
pub inline: usize,
pub file: usize,
pub missing: usize,
pub runtimes: Vec<String>,
pub entries: Vec<ComputationEntry>,
}
impl ComputationReport {
#[must_use]
pub fn is_clean(&self) -> bool {
self.incomplete() == 0
}
#[must_use]
pub fn incomplete(&self) -> usize {
self.entries
.iter()
.filter(|e| e.runtime.is_none() || e.source == "missing" || e.redundant_inline)
.count()
}
}
pub fn computation_report(root: &Path) -> Result<ComputationReport, InspectError> {
let bundle = load(root)?;
let mut report = ComputationReport {
root: root.display().to_string(),
concepts: bundle.concepts().len(),
computations: 0,
inline: 0,
file: 0,
missing: 0,
runtimes: Vec::new(),
entries: Vec::new(),
};
let mut runtimes = std::collections::BTreeSet::new();
for concept in bundle.concepts() {
let Some(computation) = concept.attested_computation() else {
continue;
};
report.computations += 1;
if let Some(runtime) = computation.runtime.as_deref() {
runtimes.insert(runtime.to_owned());
}
let (source, file, language, lines) = match &computation.computation {
okf_core::ComputationSource::Inline(inline) => {
report.inline += 1;
(
"inline",
None,
inline.language.clone(),
Some(inline.code.lines().count()),
)
}
okf_core::ComputationSource::File(path) => {
report.file += 1;
("file", Some(path.clone()), None, None)
}
okf_core::ComputationSource::Missing => {
report.missing += 1;
("missing", None, None, None)
}
};
report.entries.push(ComputationEntry {
concept: concept.id.to_string(),
path: concept
.path
.strip_prefix(bundle.root())
.unwrap_or(&concept.path)
.display()
.to_string(),
runtime: computation.runtime.clone(),
source,
file,
language,
lines,
parameters: computation
.parameters
.iter()
.filter_map(|p| p.name.clone())
.collect(),
has_executor: computation.executor.is_some(),
has_attester: computation.attester.is_some(),
redundant_inline: computation.has_redundant_inline,
});
}
report.runtimes = runtimes.into_iter().collect();
Ok(report)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BundleFile {
pub path: String,
pub bytes: Option<u64>,
pub extension: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub struct BundleContents {
pub files: Vec<BundleFile>,
pub unreadable: Vec<String>,
}
impl BundleContents {
#[must_use]
pub fn is_complete(&self) -> bool {
self.unreadable.is_empty()
}
}
#[must_use]
pub fn bundle_files(root: &Path) -> BundleContents {
let mut out = BundleContents::default();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
out.unreadable.push(relative(root, &dir));
continue;
};
for entry in entries {
let Ok(entry) = entry else {
out.unreadable.push(relative(root, &dir));
continue;
};
let path = entry.path();
let Ok(kind) = entry.file_type() else {
out.unreadable.push(relative(root, &path));
continue;
};
if kind.is_dir() {
stack.push(path);
continue;
}
let extension = path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.unwrap_or_default();
if extension == "md" {
continue;
}
out.files.push(BundleFile {
path: relative(root, &path),
bytes: std::fs::symlink_metadata(&path).map(|m| m.len()).ok(),
extension,
});
}
}
out.files.sort_by(|a, b| a.path.cmp(&b.path));
out.unreadable.sort();
out.unreadable.dedup();
out
}
fn relative(root: &Path, path: &Path) -> String {
let rel = path
.strip_prefix(root)
.unwrap_or_else(|_| Path::new(path.file_name().unwrap_or(std::ffi::OsStr::new("?"))));
let joined = rel
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/");
if joined.is_empty() {
".".to_owned()
} else {
joined
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BundleInfo {
pub root: String,
pub okf_version: Option<String>,
pub title: Option<String>,
pub concepts: usize,
pub trust: TrustSummary,
pub statuses: Vec<(String, usize)>,
pub links: (usize, usize),
pub computations: (usize, usize),
pub runtimes: Vec<String>,
pub files: BundleContents,
}
pub fn bundle_info(root: &Path, today: Option<&str>) -> Result<BundleInfo, InspectError> {
let today = resolve_today(today)?;
let bundle = load(root)?;
let trust = summarise_trust(&bundle, &root.display().to_string(), today);
let links = link_report(root)?;
let computations = computation_report(root)?;
let mut statuses: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
for concept in bundle.concepts() {
*statuses.entry(concept.status().to_string()).or_default() += 1;
}
Ok(BundleInfo {
root: root.display().to_string(),
okf_version: bundle.okf_version().map(ToOwned::to_owned),
title: bundle_title(&bundle),
concepts: bundle.concepts().len(),
trust,
statuses: statuses.into_iter().collect(),
links: (links.links, links.broken.len()),
computations: (computations.computations, computations.incomplete()),
runtimes: computations.runtimes,
files: bundle_files(root),
})
}
fn bundle_title(bundle: &Bundle) -> Option<String> {
let path = bundle.index_files().first()?;
let text = std::fs::read_to_string(path).ok()?;
let document = okf_core::Document::parse(&text).ok()?;
document
.frontmatter
.title()
.map(std::borrow::Cow::into_owned)
}