Skip to main content

turbovault_core/
okf.rs

1//! Open Knowledge Format (OKF) support.
2//!
3//! [OKF](https://github.com/GoogleCloudPlatform/knowledge-catalog) is an open,
4//! vendor-neutral format for representing knowledge as a directory of markdown
5//! files with YAML frontmatter. It is deliberately close to the substrate
6//! TurboVault already operates on (markdown + frontmatter + cross-links + index
7//! files), so this module layers OKF *semantics* on top of the existing
8//! [`Frontmatter`] / [`VaultFile`] model rather than introducing a parallel one.
9//!
10//! What this module provides:
11//! - Frontmatter accessors for the OKF-recommended fields (`type`, `title`,
12//!   `description`, `resource`, `timestamp`).
13//! - [`concept_id`] — the OKF identity of a document (its bundle-relative path
14//!   minus the `.md` suffix).
15//! - Reserved-filename detection (`index.md`, `log.md`).
16//! - [`normalize_link_target`] — turns an OKF cross-link target
17//!   (`/tables/orders.md`, `./customers.md`) into the form the link graph
18//!   resolves against.
19//! - [`Citation`] — the `# Citations` convention type (parsing lives in the
20//!   parser crate; the shared type lives here).
21//! - [`check_concept`] — per-document conformance per OKF v0.1 §9.
22//!
23//! See the spec for details. OKF is intentionally permissive: unknown `type`
24//! values, extra frontmatter keys, and broken cross-links are all valid.
25
26use std::path::Path;
27
28use serde::{Deserialize, Serialize};
29
30use crate::models::{Frontmatter, VaultFile};
31
32/// OKF frontmatter accessors layered over the generic [`Frontmatter`] map.
33///
34/// These read the small set of OKF-recommended keys. Only `type` is required
35/// by the spec; the rest are optional.
36impl Frontmatter {
37    /// Read a string-valued frontmatter field, trimming surrounding whitespace.
38    ///
39    /// Returns `None` for missing keys, non-string values, or empty strings.
40    fn okf_str_field(&self, key: &str) -> Option<String> {
41        let s = self.data.get(key)?.as_str()?.trim();
42        if s.is_empty() {
43            None
44        } else {
45            Some(s.to_string())
46        }
47    }
48
49    /// The OKF `type` — the only required field. Identifies the kind of concept
50    /// (e.g. `BigQuery Table`, `Playbook`, `Reference`). Not registered
51    /// centrally; consumers must tolerate unknown values.
52    pub fn okf_type(&self) -> Option<String> {
53        self.okf_str_field("type")
54    }
55
56    /// The OKF `title` — human-readable display name.
57    pub fn okf_title(&self) -> Option<String> {
58        self.okf_str_field("title")
59    }
60
61    /// The OKF `description` — one-line summary, used for index entries and
62    /// search snippets.
63    pub fn okf_description(&self) -> Option<String> {
64        self.okf_str_field("description")
65    }
66
67    /// The OKF `resource` — canonical URI for the underlying asset, if any.
68    pub fn okf_resource(&self) -> Option<String> {
69        self.okf_str_field("resource")
70    }
71
72    /// The OKF `timestamp` — ISO 8601 datetime of last meaningful change (raw
73    /// string as authored).
74    pub fn okf_timestamp(&self) -> Option<String> {
75        self.okf_str_field("timestamp")
76    }
77
78    /// True if this frontmatter carries a non-empty OKF `type`, the minimum bar
79    /// for an OKF-conformant concept document (§9).
80    pub fn is_okf_concept(&self) -> bool {
81        self.okf_type().is_some()
82    }
83}
84
85/// A reserved OKF filename with defined meaning at any level of the hierarchy.
86///
87/// Reserved files MUST NOT be used for concept documents (spec §3.1).
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "lowercase")]
90pub enum ReservedFile {
91    /// `index.md` — directory listing for progressive disclosure (§6).
92    Index,
93    /// `log.md` — chronological update history (§7).
94    Log,
95}
96
97impl ReservedFile {
98    /// The on-disk filename for this reserved file.
99    pub fn filename(self) -> &'static str {
100        match self {
101            ReservedFile::Index => "index.md",
102            ReservedFile::Log => "log.md",
103        }
104    }
105}
106
107/// Classify a path's filename as an OKF reserved file, if it is one.
108///
109/// Matching is case-insensitive, mirroring TurboVault's link resolution.
110pub fn reserved_file(path: &Path) -> Option<ReservedFile> {
111    let name = path.file_name()?.to_str()?.to_ascii_lowercase();
112    match name.as_str() {
113        "index.md" => Some(ReservedFile::Index),
114        "log.md" => Some(ReservedFile::Log),
115        _ => None,
116    }
117}
118
119/// Compute the OKF **concept ID** for a document: its path within the bundle
120/// with the `.md` suffix removed and `/` separators.
121///
122/// `bundle_root` is the bundle (vault) root; `path` may be absolute (under the
123/// root) or already bundle-relative. For example, with a root of `/vault`,
124/// `/vault/tables/users.md` has concept ID `tables/users`.
125///
126/// Falls back to the file stem when `path` is not under `bundle_root`.
127pub fn concept_id(bundle_root: &Path, path: &Path) -> String {
128    let rel = path.strip_prefix(bundle_root).unwrap_or(path);
129    let s = rel.to_string_lossy().replace('\\', "/");
130    let s = s.trim_start_matches('/');
131    s.strip_suffix(".md").unwrap_or(s).to_string()
132}
133
134/// Normalize an OKF cross-link / citation target into the path-shaped form the
135/// link graph resolves against.
136///
137/// Handles the two OKF link forms (spec §5):
138/// - **Bundle-relative** (`/tables/orders.md`) — leading `/` stripped.
139/// - **Relative** (`./other.md`, `../x.md`) — `.`/`..` segments dropped.
140///
141/// Any `#heading` / `#^block` fragment is removed, the `.md` suffix is stripped,
142/// and the result is lowercased into `/`-joined path components for
143/// suffix-matching. Returns `None` for external URLs, pure anchors, or empty
144/// targets (nothing a vault file could resolve to).
145///
146/// # Examples
147/// ```
148/// use turbovault_core::okf::normalize_link_target;
149///
150/// assert_eq!(normalize_link_target("/tables/orders.md"), Some(vec!["tables".into(), "orders".into()]));
151/// assert_eq!(normalize_link_target("./customers.md#schema"), Some(vec!["customers".into()]));
152/// assert_eq!(normalize_link_target("https://example.com"), None);
153/// assert_eq!(normalize_link_target("#section"), None);
154/// ```
155pub fn normalize_link_target(target: &str) -> Option<Vec<String>> {
156    // External links and pure anchors never resolve to a vault file.
157    if target.starts_with("http://")
158        || target.starts_with("https://")
159        || target.starts_with("mailto:")
160        || target.starts_with('#')
161    {
162        return None;
163    }
164
165    // Drop any heading/block fragment.
166    let path_part = target.split('#').next().unwrap_or("").trim();
167    if path_part.is_empty() {
168        return None;
169    }
170
171    let parts: Vec<String> = path_part
172        .split(['/', '\\'])
173        .filter(|seg| !seg.is_empty() && *seg != "." && *seg != "..")
174        .map(|seg| {
175            let lower = seg.to_lowercase();
176            lower.strip_suffix(".md").unwrap_or(&lower).to_string()
177        })
178        .collect();
179
180    if parts.is_empty() { None } else { Some(parts) }
181}
182
183/// A citation backing a claim in a concept body (spec §8).
184///
185/// Citations are numbered markdown links listed under a `# Citations` heading.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct Citation {
188    /// The `[N]` ordinal, when present.
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub index: Option<u32>,
191    /// The link text (the cited source's display name).
192    pub text: String,
193    /// The citation target — an external URL or a bundle/relative path.
194    pub url: String,
195}
196
197/// Per-document OKF conformance result (spec §9).
198///
199/// A document is conformant when it has parseable frontmatter carrying a
200/// non-empty `type`. Reserved files (`index.md`/`log.md`) are exempt from the
201/// `type` requirement — they are structural, not concepts.
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct ConceptConformance {
204    /// Whether the document is conformant.
205    pub conformant: bool,
206    /// Whether a frontmatter block was present and parseable.
207    pub has_frontmatter: bool,
208    /// Whether a non-empty `type` field was present.
209    pub has_type: bool,
210    /// The reserved-file kind, if this path is a reserved file.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub reserved: Option<ReservedFile>,
213    /// Human-readable issues explaining any non-conformance.
214    #[serde(default, skip_serializing_if = "Vec::is_empty")]
215    pub issues: Vec<String>,
216}
217
218/// Check a single document for OKF v0.1 conformance (§9).
219///
220/// `frontmatter` is the already-parsed frontmatter (or `None` if absent /
221/// unparseable). `path` determines reserved-file exemption.
222pub fn check_concept(frontmatter: Option<&Frontmatter>, path: &Path) -> ConceptConformance {
223    let reserved = reserved_file(path);
224    let has_frontmatter = frontmatter.is_some();
225    let has_type = frontmatter.is_some_and(Frontmatter::is_okf_concept);
226    let mut issues = Vec::new();
227
228    // Reserved files are structural; they are conformant without a `type`.
229    if reserved.is_some() {
230        return ConceptConformance {
231            conformant: true,
232            has_frontmatter,
233            has_type,
234            reserved,
235            issues,
236        };
237    }
238
239    if !has_frontmatter {
240        issues.push("missing parseable YAML frontmatter block".to_string());
241    } else if !has_type {
242        issues.push("frontmatter is missing a non-empty `type` field".to_string());
243    }
244
245    ConceptConformance {
246        conformant: issues.is_empty(),
247        has_frontmatter,
248        has_type,
249        reserved,
250        issues,
251    }
252}
253
254/// Minimum fraction of non-reserved documents that must carry a `type` for a
255/// vault to be flagged an OKF bundle on metadata alone (a root `index.md` also
256/// qualifies it — see [`detect_bundle`]).
257const BUNDLE_CONCEPT_RATIO_THRESHOLD: f64 = 0.5;
258
259/// Bundle-level OKF signals — orientation for a consumer landing in a vault.
260///
261/// This answers an agent's first questions on connecting: *is this an OKF
262/// bundle, and where do I start?* It is a cheap heuristic over already-parsed
263/// frontmatter, not a conformance verdict — use [`check_concept`] /
264/// `okf_validate` for that.
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct BundleInfo {
267    /// Heuristic: is this vault shaped like an OKF bundle? True when it has at
268    /// least one concept document and either a root `index.md` or a majority of
269    /// non-reserved documents carry a `type`.
270    pub is_okf_bundle: bool,
271    /// Total markdown documents considered.
272    pub total_docs: usize,
273    /// Non-reserved documents carrying a non-empty OKF `type` (concepts).
274    pub concept_docs: usize,
275    /// Reserved files (`index.md` / `log.md`) anywhere in the bundle.
276    pub reserved_files: usize,
277    /// Fraction of non-reserved documents that are OKF concepts (0.0–1.0).
278    pub concept_ratio: f64,
279    /// Whether the bundle root has an `index.md` — the progressive-disclosure
280    /// entry point (§6). This is the path a consumer should read first.
281    pub has_root_index: bool,
282    /// Whether the bundle root has a `log.md` (§7).
283    pub has_root_log: bool,
284    /// Concept `type` vocabulary, most-common first (ties broken by name).
285    pub top_types: Vec<(String, usize)>,
286}
287
288/// Detect whether a vault is an OKF bundle and surface orientation signals.
289///
290/// `root` is the vault/bundle root; `files` are its already-parsed documents
291/// (typically the cache-validated set). Reserved files (`index.md`/`log.md`)
292/// are excluded from the concept ratio — they are structural, not concepts.
293pub fn detect_bundle(root: &Path, files: &[VaultFile]) -> BundleInfo {
294    let total_docs = files.len();
295    let mut reserved_files = 0usize;
296    let mut non_reserved = 0usize;
297    let mut concept_docs = 0usize;
298    let mut has_root_index = false;
299    let mut has_root_log = false;
300    let mut type_counts: std::collections::BTreeMap<String, usize> =
301        std::collections::BTreeMap::new();
302
303    for vf in files {
304        match reserved_file(&vf.path) {
305            Some(kind) => {
306                reserved_files += 1;
307                if vf.path.parent() == Some(root) {
308                    match kind {
309                        ReservedFile::Index => has_root_index = true,
310                        ReservedFile::Log => has_root_log = true,
311                    }
312                }
313            }
314            None => {
315                non_reserved += 1;
316                if let Some(t) = vf.frontmatter.as_ref().and_then(Frontmatter::okf_type) {
317                    concept_docs += 1;
318                    *type_counts.entry(t).or_insert(0) += 1;
319                }
320            }
321        }
322    }
323
324    let concept_ratio = if non_reserved == 0 {
325        0.0
326    } else {
327        concept_docs as f64 / non_reserved as f64
328    };
329
330    let is_okf_bundle =
331        concept_docs >= 1 && (concept_ratio >= BUNDLE_CONCEPT_RATIO_THRESHOLD || has_root_index);
332
333    // Most-common type first; ties broken alphabetically (BTreeMap key order).
334    let mut top_types: Vec<(String, usize)> = type_counts.into_iter().collect();
335    top_types.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
336
337    BundleInfo {
338        is_okf_bundle,
339        total_docs,
340        concept_docs,
341        reserved_files,
342        concept_ratio,
343        has_root_index,
344        has_root_log,
345        top_types,
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use crate::models::SourcePosition;
353    use std::collections::HashMap;
354    use std::path::PathBuf;
355
356    fn fm(pairs: &[(&str, serde_json::Value)]) -> Frontmatter {
357        let mut data = HashMap::new();
358        for (k, v) in pairs {
359            data.insert((*k).to_string(), v.clone());
360        }
361        Frontmatter {
362            data,
363            position: SourcePosition::start(),
364        }
365    }
366
367    #[test]
368    fn accessors_read_recommended_fields() {
369        let f = fm(&[
370            ("type", serde_json::json!("BigQuery Table")),
371            ("title", serde_json::json!("Customer Orders")),
372            ("description", serde_json::json!("One row per order.")),
373            (
374                "resource",
375                serde_json::json!("https://console.cloud.google.com/x"),
376            ),
377            ("timestamp", serde_json::json!("2026-05-28T14:30:00Z")),
378        ]);
379        assert_eq!(f.okf_type().as_deref(), Some("BigQuery Table"));
380        assert_eq!(f.okf_title().as_deref(), Some("Customer Orders"));
381        assert_eq!(f.okf_description().as_deref(), Some("One row per order."));
382        assert_eq!(
383            f.okf_resource().as_deref(),
384            Some("https://console.cloud.google.com/x")
385        );
386        assert_eq!(f.okf_timestamp().as_deref(), Some("2026-05-28T14:30:00Z"));
387        assert!(f.is_okf_concept());
388    }
389
390    #[test]
391    fn empty_and_missing_fields_are_none() {
392        let f = fm(&[("type", serde_json::json!("   "))]);
393        assert_eq!(f.okf_type(), None);
394        assert!(!f.is_okf_concept());
395        let g = fm(&[]);
396        assert_eq!(g.okf_title(), None);
397    }
398
399    #[test]
400    fn reserved_files_detected_case_insensitively() {
401        assert_eq!(
402            reserved_file(Path::new("/v/index.md")),
403            Some(ReservedFile::Index)
404        );
405        assert_eq!(
406            reserved_file(Path::new("/v/sub/LOG.md")),
407            Some(ReservedFile::Log)
408        );
409        assert_eq!(reserved_file(Path::new("/v/orders.md")), None);
410    }
411
412    #[test]
413    fn concept_id_strips_root_and_suffix() {
414        let root = PathBuf::from("/vault");
415        assert_eq!(
416            concept_id(&root, &PathBuf::from("/vault/tables/users.md")),
417            "tables/users"
418        );
419        assert_eq!(
420            concept_id(&root, &PathBuf::from("tables/users.md")),
421            "tables/users"
422        );
423    }
424
425    #[test]
426    fn normalize_targets() {
427        assert_eq!(
428            normalize_link_target("/tables/orders.md"),
429            Some(vec!["tables".to_string(), "orders".to_string()])
430        );
431        assert_eq!(
432            normalize_link_target("./customers.md#schema"),
433            Some(vec!["customers".to_string()])
434        );
435        assert_eq!(
436            normalize_link_target("../shared/glossary.md"),
437            Some(vec!["shared".to_string(), "glossary".to_string()])
438        );
439        assert_eq!(normalize_link_target("https://example.com"), None);
440        assert_eq!(normalize_link_target("#anchor"), None);
441        assert_eq!(normalize_link_target(""), None);
442        // Backslash separators normalize the same as `/` (consistent with concept_id).
443        assert_eq!(
444            normalize_link_target("\\tables\\orders.md"),
445            Some(vec!["tables".to_string(), "orders".to_string()])
446        );
447    }
448
449    #[test]
450    fn conformance_requires_type_for_concepts() {
451        let ok = check_concept(
452            Some(&fm(&[("type", serde_json::json!("Playbook"))])),
453            Path::new("/v/playbooks/x.md"),
454        );
455        assert!(ok.conformant);
456
457        let no_type = check_concept(Some(&fm(&[])), Path::new("/v/x.md"));
458        assert!(!no_type.conformant);
459        assert!(no_type.has_frontmatter);
460        assert!(!no_type.has_type);
461        assert_eq!(no_type.issues.len(), 1);
462
463        let no_fm = check_concept(None, Path::new("/v/x.md"));
464        assert!(!no_fm.conformant);
465        assert!(!no_fm.has_frontmatter);
466    }
467
468    #[test]
469    fn reserved_files_are_conformant_without_type() {
470        let idx = check_concept(None, Path::new("/v/index.md"));
471        assert!(idx.conformant);
472        assert_eq!(idx.reserved, Some(ReservedFile::Index));
473        assert!(idx.issues.is_empty());
474    }
475
476    fn vfile(path: &str, type_: Option<&str>) -> VaultFile {
477        use crate::models::FileMetadata;
478        let p = PathBuf::from(path);
479        let meta = FileMetadata {
480            path: p.clone(),
481            size: 0,
482            created_at: 0.0,
483            modified_at: 0.0,
484            checksum: String::new(),
485            is_attachment: false,
486        };
487        let mut vf = VaultFile::new(p, String::new(), meta);
488        vf.frontmatter = type_.map(|t| fm(&[("type", serde_json::json!(t))]));
489        vf
490    }
491
492    #[test]
493    fn detect_bundle_flags_a_typed_vault() {
494        let root = PathBuf::from("/v");
495        let files = vec![
496            vfile("/v/tables/orders.md", Some("BigQuery Table")),
497            vfile("/v/tables/customers.md", Some("BigQuery Table")),
498            vfile("/v/playbooks/etl.md", Some("Playbook")),
499            vfile("/v/index.md", None),
500            vfile("/v/log.md", None),
501        ];
502        let info = detect_bundle(&root, &files);
503        assert!(info.is_okf_bundle);
504        assert_eq!(info.total_docs, 5);
505        assert_eq!(info.concept_docs, 3);
506        assert_eq!(info.reserved_files, 2);
507        assert_eq!(info.concept_ratio, 1.0);
508        assert!(info.has_root_index);
509        assert!(info.has_root_log);
510        // Most-common type first; ties broken alphabetically.
511        assert_eq!(
512            info.top_types,
513            vec![
514                ("BigQuery Table".to_string(), 2),
515                ("Playbook".to_string(), 1)
516            ]
517        );
518    }
519
520    #[test]
521    fn detect_bundle_ignores_plain_obsidian_vault() {
522        let root = PathBuf::from("/v");
523        // Untyped notes, even with a stray index.md, are not an OKF bundle.
524        let files = vec![
525            vfile("/v/daily/monday.md", None),
526            vfile("/v/ideas.md", None),
527            vfile("/v/index.md", None),
528        ];
529        let info = detect_bundle(&root, &files);
530        assert!(!info.is_okf_bundle);
531        assert_eq!(info.concept_docs, 0);
532        assert_eq!(info.concept_ratio, 0.0);
533        assert!(info.has_root_index);
534        assert!(info.top_types.is_empty());
535    }
536
537    #[test]
538    fn detect_bundle_root_index_qualifies_below_ratio() {
539        let root = PathBuf::from("/v");
540        // One typed concept out of three (ratio 0.33 < 0.5), but a root index.md
541        // present plus at least one concept → still a bundle.
542        let files = vec![
543            vfile("/v/orders.md", Some("Table")),
544            vfile("/v/notes.md", None),
545            vfile("/v/scratch.md", None),
546            vfile("/v/index.md", None),
547        ];
548        let info = detect_bundle(&root, &files);
549        assert!(info.concept_ratio < BUNDLE_CONCEPT_RATIO_THRESHOLD);
550        assert!(info.has_root_index);
551        assert!(info.is_okf_bundle);
552    }
553
554    #[test]
555    fn detect_bundle_nested_reserved_not_counted_as_root() {
556        let root = PathBuf::from("/v");
557        let files = vec![
558            vfile("/v/tables/orders.md", Some("Table")),
559            vfile("/v/tables/index.md", None), // nested index, not root
560        ];
561        let info = detect_bundle(&root, &files);
562        assert!(!info.has_root_index);
563        assert_eq!(info.reserved_files, 1);
564        // Ratio is 1.0 (one concept, one reserved excluded) → still a bundle.
565        assert!(info.is_okf_bundle);
566    }
567}