1use crate::graph::GraphModel;
10use crate::search::{SearchEntry, SearchIndex};
11use okf_core::log::{Log, LogEntry};
12use okf_core::{
13 ActorKind, AttestedComputation, Bundle, BundleError, ComputationSource, ConceptId, Date,
14 DateTimeField, Status, TrustTier,
15};
16use okf_validator::{Report, check_syntax, lint_bundle_at, validate_bundle_at};
17use std::collections::{BTreeMap, HashMap};
18use std::path::PathBuf;
19
20#[derive(Clone, Debug)]
22pub struct ConceptMeta {
23 pub tier: TrustTier,
25 pub status: Status,
27 pub stale: bool,
29 pub stale_in_days: Option<i64>,
31 pub overdue_days: Option<i64>,
33 pub stale_after: Option<DateTimeField>,
35 pub diag_errors: usize,
37 pub diag_warnings: usize,
39 pub lint_findings: usize,
41 pub out_degree: usize,
43 pub in_degree: usize,
45 pub broken_out: usize,
47 pub source_count: usize,
49 pub is_computation: bool,
51 pub headings: Vec<(usize, String)>,
53}
54
55#[derive(Clone, Debug, Default)]
57pub struct ActorStats {
58 pub kind: Option<ActorKind>,
60 pub generated: usize,
62 pub verified: usize,
64}
65
66#[derive(Clone, Debug, Default)]
68pub struct BundleStats {
69 pub concepts: usize,
71 pub parse_errors: usize,
73 pub tier_counts: [usize; 3],
75 pub status_counts: [usize; 4],
77 pub stale: usize,
79 pub stale_soon: usize,
81 pub broken_links: usize,
83 pub types: BTreeMap<String, usize>,
85 pub actors: BTreeMap<String, ActorStats>,
87}
88
89#[derive(Clone, Debug)]
91pub struct AttentionItem {
92 pub id: ConceptId,
94 pub risk: i64,
96 pub reasons: Vec<String>,
98}
99
100#[derive(Clone, Debug)]
102pub struct ContractInfo {
103 pub id: ConceptId,
105 pub title: String,
107 pub contract: AttestedComputation,
109 pub path_checks: Vec<(String, String, Option<PathBuf>)>,
111 pub syntax: Option<Result<(), String>>,
114 pub issues: Vec<String>,
116}
117
118impl ContractInfo {
119 #[must_use]
121 pub const fn healthy(&self) -> bool {
122 self.issues.is_empty()
123 }
124}
125
126#[derive(Clone, Debug, PartialEq, Eq)]
128pub enum LeafKind {
129 Concept(ConceptId),
131 Index(PathBuf),
133 Log(PathBuf),
135 Broken(PathBuf),
137}
138
139#[derive(Clone, Debug)]
141pub enum TreeNode {
142 Dir {
144 name: String,
146 path: String,
148 concept_count: usize,
150 children: Vec<Self>,
152 },
153 Leaf {
155 name: String,
157 kind: LeafKind,
159 },
160}
161
162#[derive(Clone, Debug, Default)]
164pub struct FileTree {
165 pub roots: Vec<TreeNode>,
167}
168
169#[derive(Debug)]
171pub struct Snapshot {
172 pub generation: u64,
174 pub today: Date,
176 pub bundle: Bundle,
178 pub validation: Report,
180 pub lint: Report,
182 pub tree: FileTree,
184 pub concept_meta: HashMap<ConceptId, ConceptMeta>,
186 pub graph: GraphModel,
188 pub search: SearchIndex,
190 pub log_timeline: Vec<(Date, usize)>,
192 pub log_days: Vec<(String, Vec<LogEntry>)>,
194 pub stats: BundleStats,
196 pub attention: Vec<AttentionItem>,
198 pub contracts: Vec<ContractInfo>,
200}
201
202impl Snapshot {
203 pub fn build(
210 root: &std::path::Path,
211 today: Option<Date>,
212 generation: u64,
213 ) -> Result<Self, BundleError> {
214 let bundle = Bundle::load(root)?;
215 let today = today.or_else(Date::today_utc).unwrap_or(Date {
216 year: 2026,
217 month: 1,
218 day: 1,
219 });
220 let validation = validate_bundle_at(&bundle, Some(today));
221 let lint = lint_bundle_at(&bundle, Some(today));
222
223 let concept_meta = build_meta(&bundle, &validation, &lint, today);
224 let tree = build_tree(&bundle);
225 let graph = GraphModel::build(&bundle);
226 let search = build_search(&bundle, &concept_meta);
227 let (log_timeline, log_days) = build_log_views(&bundle);
228 let stats = build_stats(&bundle, &concept_meta);
229 let attention = build_attention(&bundle, &concept_meta);
230 let contracts = build_contracts(&bundle);
231
232 Ok(Self {
233 generation,
234 today,
235 bundle,
236 validation,
237 lint,
238 tree,
239 concept_meta,
240 graph,
241 search,
242 log_timeline,
243 log_days,
244 stats,
245 attention,
246 contracts,
247 })
248 }
249
250 #[must_use]
252 pub fn meta(&self, id: &ConceptId) -> Option<&ConceptMeta> {
253 self.concept_meta.get(id)
254 }
255}
256
257fn days_between(from: Date, to: Date) -> i64 {
258 to.days_since_epoch() - from.days_since_epoch()
259}
260
261fn build_meta(
262 bundle: &Bundle,
263 validation: &Report,
264 lint: &Report,
265 today: Date,
266) -> HashMap<ConceptId, ConceptMeta> {
267 let mut meta = HashMap::new();
268 for concept in bundle.concepts() {
269 let fm = &concept.document.frontmatter;
270 let stale_after = fm.stale_after();
271 let stale = concept.is_stale_on(today);
272 let effective = stale_after.as_ref().and_then(|f| {
273 f.datetime
274 .map(|dt| dt.utc_date())
275 .or_else(|| Date::parse(f.raw.trim().get(..10).unwrap_or("")))
276 });
277 let (stale_in_days, overdue_days) = effective.map_or((None, None), |date| {
278 let delta = days_between(today, date);
279 if stale {
280 (None, Some(-delta))
281 } else if (0..=30).contains(&delta) {
282 (Some(delta), None)
283 } else {
284 (None, None)
285 }
286 });
287 let links = bundle.links_from(&concept.id);
288 let count_for = |report: &Report, severity: Option<okf_validator::Severity>| {
289 report
290 .diagnostics
291 .iter()
292 .filter(|d| {
293 d.concept.as_ref() == Some(&concept.id)
294 || d.path.as_ref() == Some(&concept.path)
295 })
296 .filter(|d| severity.is_none_or(|s| d.severity == s))
297 .count()
298 };
299 meta.insert(
300 concept.id.clone(),
301 ConceptMeta {
302 tier: concept.trust_tier(),
303 status: concept.status(),
304 stale,
305 stale_in_days,
306 overdue_days,
307 stale_after,
308 diag_errors: count_for(validation, Some(okf_validator::Severity::Error)),
309 diag_warnings: count_for(validation, Some(okf_validator::Severity::Warning)),
310 lint_findings: count_for(lint, None),
311 out_degree: links.len(),
312 in_degree: bundle.backlinks(&concept.id).len(),
313 broken_out: links.iter().filter(|l| !l.exists).count(),
314 source_count: concept.sources().len(),
315 is_computation: concept.document.frontmatter.is_attested_computation(),
316 headings: okf_core::extract_headings(&concept.document.body)
317 .iter()
318 .map(|h| (h.level, h.text.to_string()))
319 .collect(),
320 },
321 );
322 }
323 meta
324}
325
326#[allow(clippy::too_many_lines)]
327fn build_tree(bundle: &Bundle) -> FileTree {
328 #[derive(Default)]
330 struct DirBuilder {
331 dirs: BTreeMap<String, Self>,
332 leaves: Vec<(String, LeafKind)>,
333 concepts: usize,
334 }
335 fn insert(root: &mut DirBuilder, segments: &[String], leaf: (String, LeafKind)) {
336 let is_concept = matches!(leaf.1, LeafKind::Concept(_));
337 let mut cur = root;
338 if is_concept {
339 cur.concepts += 1;
340 }
341 for seg in segments {
342 cur = cur.dirs.entry(seg.clone()).or_default();
343 if is_concept {
344 cur.concepts += 1;
345 }
346 }
347 cur.leaves.push(leaf);
348 }
349 fn finish(builder: DirBuilder, prefix: &str) -> Vec<TreeNode> {
350 let mut reserved: Vec<TreeNode> = Vec::new();
351 let mut leaves: Vec<TreeNode> = Vec::new();
352 let mut sorted = builder.leaves;
353 sorted.sort_by(|a, b| a.0.cmp(&b.0));
354 for (name, kind) in sorted {
355 let node = TreeNode::Leaf { name, kind };
356 match &node {
357 TreeNode::Leaf {
358 kind: LeafKind::Index(_) | LeafKind::Log(_),
359 ..
360 } => reserved.push(node),
361 _ => leaves.push(node),
362 }
363 }
364 reserved.sort_by_key(|n| match n {
365 TreeNode::Leaf {
366 kind: LeafKind::Index(_),
367 ..
368 } => 0,
369 _ => 1,
370 });
371 let mut dirs: Vec<TreeNode> = Vec::new();
372 for (name, child) in builder.dirs {
373 let path = if prefix.is_empty() {
374 name.clone()
375 } else {
376 format!("{prefix}/{name}")
377 };
378 let concept_count = child.concepts;
379 let children = finish(child, &path);
380 dirs.push(TreeNode::Dir {
381 name,
382 path,
383 concept_count,
384 children,
385 });
386 }
387 let mut out = reserved;
388 out.extend(dirs);
389 out.extend(leaves);
390 out
391 }
392
393 let mut root = DirBuilder::default();
394 let rel_segments = |path: &std::path::Path| -> Option<(Vec<String>, String)> {
395 let rel = path.strip_prefix(bundle.root()).ok()?;
396 let mut segs: Vec<String> = rel
397 .components()
398 .filter_map(|c| match c {
399 std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
400 _ => None,
401 })
402 .collect();
403 let name = segs.pop()?;
404 Some((segs, name))
405 };
406 for concept in bundle.concepts() {
407 let segments = concept.id.segments();
408 let dirs = segments[..segments.len() - 1].to_vec();
409 insert(
410 &mut root,
411 &dirs,
412 (
413 format!("{}.md", concept.id.name()),
414 LeafKind::Concept(concept.id.clone()),
415 ),
416 );
417 }
418 for path in bundle.index_files() {
419 if let Some((dirs, name)) = rel_segments(path) {
420 insert(&mut root, &dirs, (name, LeafKind::Index(path.clone())));
421 }
422 }
423 for path in bundle.log_files() {
424 if let Some((dirs, name)) = rel_segments(path) {
425 insert(&mut root, &dirs, (name, LeafKind::Log(path.clone())));
426 }
427 }
428 for (path, _) in bundle.parse_errors() {
429 if let Some((dirs, name)) = rel_segments(path) {
430 insert(&mut root, &dirs, (name, LeafKind::Broken(path.clone())));
431 }
432 }
433 FileTree {
434 roots: finish(root, ""),
435 }
436}
437
438fn build_search(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> SearchIndex {
439 let entries = bundle
440 .concepts()
441 .iter()
442 .map(|concept| {
443 let m = meta.get(&concept.id);
444 SearchEntry {
445 id: concept.id.clone(),
446 title: concept.display_title(),
447 description: concept
448 .document
449 .frontmatter
450 .description()
451 .map(std::borrow::Cow::into_owned)
452 .unwrap_or_default(),
453 tags: concept.document.frontmatter.tags(),
454 headings: m
455 .map(|m| m.headings.iter().map(|(_, t)| t.clone()).collect())
456 .unwrap_or_default(),
457 type_: concept
458 .type_()
459 .map(std::borrow::Cow::into_owned)
460 .unwrap_or_default(),
461 tier: concept.trust_tier(),
462 status: concept.status(),
463 stale: m.is_some_and(|m| m.stale),
464 broken: m.is_some_and(|m| m.broken_out > 0),
465 }
466 })
467 .collect();
468 SearchIndex { entries }
469}
470
471type LogViews = (Vec<(Date, usize)>, Vec<(String, Vec<LogEntry>)>);
474
475fn build_log_views(bundle: &Bundle) -> LogViews {
476 let mut counts: BTreeMap<Date, usize> = BTreeMap::new();
477 let mut days: BTreeMap<String, Vec<LogEntry>> = BTreeMap::new();
478 for path in bundle.log_files() {
479 let Ok(text) = std::fs::read_to_string(path) else {
480 continue;
481 };
482 let log = Log::parse(&text);
483 for day in log.days {
484 if let Some(date) = Date::parse(day.date.trim()) {
485 *counts.entry(date).or_default() += day.entries.len();
486 }
487 days.entry(day.date.clone())
488 .or_default()
489 .extend(day.entries);
490 }
491 }
492 let timeline = counts.into_iter().collect();
493 let mut merged: Vec<(String, Vec<LogEntry>)> = days.into_iter().collect();
495 merged.sort_by(|a, b| b.0.cmp(&a.0));
496 (timeline, merged)
497}
498
499fn build_stats(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> BundleStats {
500 let mut stats = BundleStats {
501 concepts: bundle.len(),
502 parse_errors: bundle.parse_errors().len(),
503 broken_links: bundle.broken_links().len(),
504 ..BundleStats::default()
505 };
506 for concept in bundle.concepts() {
507 let m = &meta[&concept.id];
508 stats.tier_counts[match m.tier {
509 TrustTier::Unverified => 0,
510 TrustTier::MachineConfirmed => 1,
511 TrustTier::HumanReviewed => 2,
512 }] += 1;
513 stats.status_counts[match m.status {
514 Status::Draft => 0,
515 Status::Stable => 1,
516 Status::Deprecated => 2,
517 Status::Other(_) => 3,
518 }] += 1;
519 if m.stale {
520 stats.stale += 1;
521 } else if m.stale_in_days.is_some() {
522 stats.stale_soon += 1;
523 }
524 let type_ = concept
525 .type_()
526 .map_or_else(|| "(untyped)".to_string(), std::borrow::Cow::into_owned);
527 *stats.types.entry(type_).or_default() += 1;
528
529 let fm = &concept.document.frontmatter;
530 if let Some(by) = fm.generated().and_then(|g| g.by) {
531 let entry = stats.actors.entry(by.as_str().to_string()).or_default();
532 entry.kind = Some(by.kind());
533 entry.generated += 1;
534 }
535 for verification in fm.verified() {
536 if let Some(by) = verification.by {
537 let entry = stats.actors.entry(by.as_str().to_string()).or_default();
538 entry.kind = Some(by.kind());
539 entry.verified += 1;
540 }
541 }
542 }
543 stats
544}
545
546fn build_attention(bundle: &Bundle, meta: &HashMap<ConceptId, ConceptMeta>) -> Vec<AttentionItem> {
547 let mut items = Vec::new();
548 for concept in bundle.concepts() {
549 let m = &meta[&concept.id];
550 let mut risk: i64 = 0;
551 let mut reasons: Vec<String> = Vec::new();
552 if let Some(overdue) = m.overdue_days {
553 risk += 40 + overdue.clamp(0, 60);
554 reasons.push(format!("stale {overdue}d"));
555 } else if let Some(days) = m.stale_in_days {
556 risk += 10 + (30 - days).max(0);
557 reasons.push(format!("stale in {days}d"));
558 } else if m.stale {
559 risk += 40;
560 reasons.push("stale".to_string());
561 }
562 match m.tier {
563 TrustTier::Unverified => {
564 risk += 25;
565 reasons.push("unverified".to_string());
566 }
567 TrustTier::MachineConfirmed => {
568 risk += 10;
569 reasons.push("machine-confirmed".to_string());
570 }
571 TrustTier::HumanReviewed => reasons.push("human-reviewed".to_string()),
572 }
573 if m.status.is_deprecated() && m.in_degree > 0 {
574 risk += 20;
575 reasons.push(format!(
576 "deprecated · {} incoming links remain",
577 m.in_degree
578 ));
579 }
580 if m.in_degree > 0 {
581 risk += i64::try_from(m.in_degree).unwrap_or(0).min(10) * 3;
582 reasons.push(format!("{} backlinks", m.in_degree));
583 }
584 let diags = m.diag_errors * 15 + m.diag_warnings * 5 + m.lint_findings * 2;
585 if diags > 0 {
586 risk += i64::try_from(diags).unwrap_or(0);
587 reasons.push(format!(
588 "{} diagnostic(s)",
589 m.diag_errors + m.diag_warnings + m.lint_findings
590 ));
591 }
592 let needs_attention = m.stale
593 || m.stale_in_days.is_some()
594 || m.tier == TrustTier::Unverified
595 || (m.status.is_deprecated() && m.in_degree > 0)
596 || m.diag_errors + m.diag_warnings > 0;
597 if needs_attention {
598 items.push(AttentionItem {
599 id: concept.id.clone(),
600 risk,
601 reasons,
602 });
603 }
604 }
605 items.sort_by(|a, b| b.risk.cmp(&a.risk).then_with(|| a.id.cmp(&b.id)));
606 items
607}
608
609fn build_contracts(bundle: &Bundle) -> Vec<ContractInfo> {
610 bundle
611 .attested_computations()
612 .filter_map(|concept| {
613 let contract = concept.attested_computation()?;
614 let mut issues = Vec::new();
615 if contract.runtime.is_none() {
616 issues.push("missing required `runtime`".to_string());
617 }
618 if contract.computation.is_missing() {
619 issues.push(
620 "no computation: neither inline block nor `computation` path".to_string(),
621 );
622 }
623 if contract.has_redundant_inline {
624 issues.push("both `computation` path and inline block present".to_string());
625 }
626 for parameter in &contract.parameters {
627 if parameter.name.is_none() {
628 issues.push("a parameter entry lacks `name`".to_string());
629 }
630 }
631 let mut path_checks = Vec::new();
632 for (field, raw) in contract.path_fields() {
633 let resolved = bundle.resolve_path_field(&concept.id, raw);
634 if resolved.is_none() {
635 issues.push(format!("{field} does not resolve: {raw}"));
636 }
637 path_checks.push((field.to_string(), raw.to_string(), resolved));
638 }
639 let syntax = match (&contract.computation, contract.runtime.as_deref()) {
640 (ComputationSource::Inline(inline), runtime) => {
641 let tag = inline
642 .language
643 .clone()
644 .or_else(|| runtime.map(String::from));
645 tag.map(|tag| check_syntax(&tag, &inline.code).map_err(|e| e.to_string()))
646 }
647 _ => None,
648 };
649 if let Some(Err(e)) = &syntax {
650 issues.push(format!("computation syntax: {e}"));
651 }
652 Some(ContractInfo {
653 id: concept.id.clone(),
654 title: concept.display_title(),
655 contract,
656 path_checks,
657 syntax,
658 issues,
659 })
660 })
661 .collect()
662}