Skip to main content

lean_ctx/core/knowledge/
okf.rs

1//! Open Knowledge Format (OKF) rendering for lean-ctx project knowledge.
2//!
3//! OKF (Google Cloud, June 2026) formalises the "LLM-wiki" pattern: a *directory
4//! of Markdown files*, one concept per file, each with a small YAML frontmatter
5//! block (only `type` is mandatory) and a Markdown body; concepts link to each
6//! other with ordinary Markdown links, and reserved `index.md` / `log.md` files
7//! provide progressive disclosure and history. It is vendor-neutral, human- and
8//! agent-readable, and git-diffable — the portable, no-lock-in counterpart to the
9//! signed `ctxpkg` bundle.
10//!
11//! This module renders a [`KnowledgeSnapshot`] to an OKF bundle and parses a
12//! bundle back into facts + relations. Both directions go through that *shared*
13//! snapshot, so OKF and ctxpkg can never disagree on what the project's
14//! knowledge is.
15//!
16//! ## Determinism (#498)
17//! Every byte of the export is a pure function of the snapshot: frontmatter keys
18//! are emitted in a fixed order (reserved OKF keys first, then sorted
19//! `leanctx_*`), file slugs are stable, relation lines are sorted, and no
20//! `now()` or counter ever reaches the output. Two exports of the same snapshot
21//! are byte-identical, which keeps provider prompt-caching effective.
22//!
23//! ## Lossless round-trip
24//! lean-ctx-specific fields ride along as producer-owned `leanctx_*` keys (OKF
25//! §reserves only a small set and lets producers add their own), so an
26//! export -> import cycle reconstructs the same facts, archetypes, and relations.
27//! Foreign bundles that carry only `type` import as plain facts rather than
28//! failing.
29
30use std::collections::{BTreeMap, HashMap, HashSet};
31use std::path::Path;
32
33use chrono::{DateTime, Utc};
34use serde_json::{Map, Value};
35use walkdir::WalkDir;
36
37use crate::core::knowledge_relations::{KnowledgeEdgeKind, KnowledgeNodeRef, parse_node_ref};
38use crate::core::memory_boundary::FactPrivacy;
39use crate::core::sensitivity;
40
41use super::snapshot::KnowledgeSnapshot;
42use super::types::{KnowledgeArchetype, KnowledgeFact, ProjectPattern};
43
44/// Reserved OKF filenames that are *not* concepts: an overview index and a
45/// chronological change log. Skipped on import.
46const INDEX_FILE: &str = "index.md";
47const LOG_FILE: &str = "log.md";
48/// Directory (one level under the bundle root) that holds project patterns.
49const PATTERNS_DIR: &str = "patterns";
50
51/// A rendered OKF bundle: relative file path -> file contents. A `BTreeMap` so
52/// iteration (and thus writing) is deterministic.
53pub type OkfBundle = BTreeMap<String, String>;
54
55/// A relation parsed from a concept's `## Relations` section.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct OkfEdge {
58    pub from: KnowledgeNodeRef,
59    pub to: KnowledgeNodeRef,
60    pub kind: KnowledgeEdgeKind,
61}
62
63/// The result of parsing an OKF directory back into lean-ctx structures.
64#[derive(Debug, Default)]
65pub struct OkfImport {
66    pub facts: Vec<KnowledgeFact>,
67    pub patterns: Vec<ProjectPattern>,
68    pub edges: Vec<OkfEdge>,
69}
70
71/// Where a concept lives inside the bundle: `<dir>/<file>.md`.
72struct ConceptLoc {
73    dir: String,
74    file: String,
75}
76
77// ---------------------------------------------------------------------------
78// Export
79// ---------------------------------------------------------------------------
80
81/// Renders a snapshot to a deterministic OKF bundle (path -> contents). Only
82/// current facts become concepts; superseded history stays in ctxpkg.
83pub fn to_okf_bundle(snapshot: &KnowledgeSnapshot) -> OkfBundle {
84    let mut facts = snapshot.current_facts();
85    facts.sort_by(|a, b| a.category.cmp(&b.category).then_with(|| a.key.cmp(&b.key)));
86
87    // Pass 1: resolve a stable, unique file location for every fact node so that
88    // relation links (pass 2) can point at real files.
89    let mut used: HashSet<(String, String)> = HashSet::new();
90    let mut paths: HashMap<KnowledgeNodeRef, ConceptLoc> = HashMap::new();
91    for f in &facts {
92        let node = KnowledgeNodeRef::new(&f.category, &f.key);
93        if paths.contains_key(&node) {
94            continue;
95        }
96        let dir = dir_slug(&f.category);
97        let file = unique_slug(&dir, &f.key, &mut used);
98        paths.insert(node, ConceptLoc { dir, file });
99    }
100
101    let mut bundle = OkfBundle::new();
102
103    // Pass 2: render each concept with its outgoing relations.
104    for f in &facts {
105        let node = KnowledgeNodeRef::new(&f.category, &f.key);
106        let Some(loc) = paths.get(&node) else {
107            continue;
108        };
109        let mut rel_lines: Vec<String> = snapshot
110            .relations
111            .iter()
112            .filter(|e| e.from == node && paths.contains_key(&e.to))
113            .map(|e| {
114                let target = &paths[&e.to];
115                let link = relative_link(&loc.dir, target);
116                format!("- {}: [{}]({link})", e.kind.as_str(), e.to.id())
117            })
118            .collect();
119        rel_lines.sort();
120        rel_lines.dedup();
121
122        bundle.insert(
123            format!("{}/{}.md", loc.dir, loc.file),
124            render_concept(f, &rel_lines),
125        );
126    }
127
128    // Patterns: one file each under patterns/.
129    let mut patterns = snapshot.patterns.clone();
130    patterns.sort_by(|a, b| {
131        a.pattern_type
132            .cmp(&b.pattern_type)
133            .then_with(|| a.description.cmp(&b.description))
134    });
135    for p in &patterns {
136        let file = unique_slug(PATTERNS_DIR, &p.pattern_type, &mut used);
137        bundle.insert(format!("{PATTERNS_DIR}/{file}.md"), render_pattern(p));
138    }
139
140    // Reserved files: index always, log only when there is history.
141    bundle.insert(INDEX_FILE.to_string(), render_index(&facts, &patterns));
142    if !snapshot.insights.is_empty() {
143        bundle.insert(LOG_FILE.to_string(), render_log(snapshot));
144    }
145
146    bundle
147}
148
149/// Writes a bundle to `dir`, creating category subdirectories as needed.
150///
151/// `dir` is the trusted export root. Below it every component — category
152/// directory and file — is checked per component and must not be a symlink,
153/// so a pre-existing bundle with a planted link cannot redirect the export.
154pub fn write_okf_bundle(dir: &Path, bundle: &OkfBundle) -> Result<(), String> {
155    std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
156    for (rel, contents) in bundle {
157        // Shareable export (as before the confined write): 0644 before umask.
158        crate::core::pathjail_write::write_below(dir, Path::new(rel), contents.as_bytes(), 0o644)?;
159    }
160    Ok(())
161}
162
163fn render_concept(f: &KnowledgeFact, rel_lines: &[String]) -> String {
164    let mut out = emit_frontmatter(&concept_frontmatter(f));
165    out.push('\n');
166    out.push_str(f.value.trim_end());
167    out.push('\n');
168    if !rel_lines.is_empty() {
169        out.push_str("\n## Relations\n\n");
170        for line in rel_lines {
171            out.push_str(line);
172            out.push('\n');
173        }
174    }
175    out
176}
177
178/// Ordered frontmatter for a fact: reserved OKF keys first (fixed order), then
179/// producer-owned `leanctx_*` keys sorted for determinism and lossless import.
180/// Emits an `f32` as its shortest round-trippable decimal so frontmatter shows
181/// `0.9` rather than the f64-widened `0.8999999761581421`. Keeps git diffs and
182/// prompt-cache bytes stable while parsing back to the same `f32` on import.
183fn clean_f32(x: f32) -> Value {
184    let parsed: f64 = format!("{x}").parse().unwrap_or_else(|_| f64::from(x));
185    serde_json::Number::from_f64(parsed).map_or(Value::Null, Value::Number)
186}
187
188fn concept_frontmatter(f: &KnowledgeFact) -> Vec<(String, Value)> {
189    let mut pairs: Vec<(String, Value)> = Vec::new();
190    pairs.push(("type".into(), Value::from(f.archetype.as_type_str())));
191    pairs.push(("title".into(), Value::from(f.key.clone())));
192    let desc = first_line(&f.value);
193    if !desc.is_empty() {
194        pairs.push(("description".into(), Value::from(desc)));
195    }
196    pairs.push(("tags".into(), Value::from(vec![f.category.clone()])));
197    pairs.push((
198        "timestamp".into(),
199        Value::from(f.last_confirmed.to_rfc3339()),
200    ));
201
202    let mut extra: BTreeMap<String, Value> = BTreeMap::new();
203    extra.insert(
204        "leanctx_origin".into(),
205        serde_json::to_value(&f.origin).unwrap_or(Value::Null),
206    );
207    extra.insert("leanctx_archetype".into(), f.archetype.as_type_str().into());
208    extra.insert("leanctx_category".into(), f.category.clone().into());
209    extra.insert("leanctx_confidence".into(), clean_f32(f.confidence));
210    extra.insert(
211        "leanctx_confirmation_count".into(),
212        f.confirmation_count.into(),
213    );
214    extra.insert(
215        "leanctx_created_at".into(),
216        f.created_at.to_rfc3339().into(),
217    );
218    extra.insert("leanctx_key".into(), f.key.clone().into());
219    extra.insert(
220        "leanctx_last_confirmed".into(),
221        f.last_confirmed.to_rfc3339().into(),
222    );
223    extra.insert("leanctx_revision_count".into(), f.revision_count.into());
224    extra.insert(
225        "leanctx_sensitivity".into(),
226        serde_json::to_value(f.sensitivity).unwrap_or_else(|_| "public".into()),
227    );
228    extra.insert(
229        "leanctx_source_session".into(),
230        f.source_session.clone().into(),
231    );
232    if let Some(vf) = f.valid_from {
233        extra.insert("leanctx_valid_from".into(), vf.to_rfc3339().into());
234    }
235    if let Some(vu) = f.valid_until {
236        extra.insert("leanctx_valid_until".into(), vu.to_rfc3339().into());
237    }
238    if let Some(s) = &f.supersedes {
239        extra.insert("leanctx_supersedes".into(), s.clone().into());
240    }
241    pairs.extend(extra);
242    pairs
243}
244
245fn render_pattern(p: &ProjectPattern) -> String {
246    let mut pairs: Vec<(String, Value)> = Vec::new();
247    pairs.push(("type".into(), Value::from("pattern")));
248    pairs.push(("title".into(), Value::from(p.pattern_type.clone())));
249    let desc = first_line(&p.description);
250    if !desc.is_empty() {
251        pairs.push(("description".into(), Value::from(desc)));
252    }
253    let mut extra: BTreeMap<String, Value> = BTreeMap::new();
254    extra.insert(
255        "leanctx_created_at".into(),
256        p.created_at.to_rfc3339().into(),
257    );
258    extra.insert("leanctx_examples".into(), Value::from(p.examples.clone()));
259    extra.insert("leanctx_kind".into(), "pattern".into());
260    extra.insert(
261        "leanctx_source_session".into(),
262        p.source_session.clone().into(),
263    );
264    pairs.extend(extra);
265
266    let mut out = emit_frontmatter(&pairs);
267    out.push('\n');
268    out.push_str(p.description.trim_end());
269    out.push('\n');
270    if !p.examples.is_empty() {
271        out.push_str("\n## Examples\n\n");
272        for ex in &p.examples {
273            out.push_str("- ");
274            out.push_str(ex.trim());
275            out.push('\n');
276        }
277    }
278    out
279}
280
281fn render_index(facts: &[&KnowledgeFact], patterns: &[ProjectPattern]) -> String {
282    let mut counts: BTreeMap<String, usize> = BTreeMap::new();
283    for f in facts {
284        *counts.entry(f.category.clone()).or_insert(0) += 1;
285    }
286    let mut out = String::from(
287        "# Knowledge Index\n\nOpen Knowledge Format (OKF) bundle exported by lean-ctx.\n\n## Concepts by category\n\n",
288    );
289    if counts.is_empty() {
290        out.push_str("_none_\n");
291    } else {
292        for (cat, n) in &counts {
293            out.push_str(&format!("- {} ({})\n", dir_slug(cat), n));
294        }
295    }
296    out.push_str(&format!("\n## Patterns ({})\n", patterns.len()));
297    out
298}
299
300fn render_log(snapshot: &KnowledgeSnapshot) -> String {
301    let mut insights = snapshot.insights.clone();
302    insights.sort_by(|a, b| {
303        a.timestamp
304            .cmp(&b.timestamp)
305            .then_with(|| a.summary.cmp(&b.summary))
306    });
307    let mut out = String::from("# Change Log\n\n");
308    for i in &insights {
309        out.push_str(&format!("## {}\n\n", i.timestamp.to_rfc3339()));
310        out.push_str(i.summary.trim());
311        out.push('\n');
312        if !i.from_sessions.is_empty() {
313            out.push_str(&format!("\n_sessions: {}_\n", i.from_sessions.join(", ")));
314        }
315        out.push('\n');
316    }
317    out
318}
319
320// ---------------------------------------------------------------------------
321// Import
322// ---------------------------------------------------------------------------
323
324/// Parses an OKF directory into facts, patterns, and relations. Lenient by
325/// design: files without a `type` (or without frontmatter) are skipped rather
326/// than failing the whole import; use [`lint_okf_bundle`] to surface those.
327pub fn from_okf_dir(dir: &Path) -> Result<OkfImport, String> {
328    if !dir.is_dir() {
329        return Err(format!("not a directory: {}", dir.display()));
330    }
331    let mut imp = OkfImport::default();
332    for path in concept_files(dir) {
333        let Ok(content) = std::fs::read_to_string(&path) else {
334            continue;
335        };
336        let Some((fm_str, body)) = split_frontmatter(&content) else {
337            continue;
338        };
339        let fm = parse_frontmatter_map(&fm_str);
340        if !fm.contains_key("type") {
341            continue;
342        }
343
344        if fm.get("leanctx_kind").and_then(Value::as_str) == Some("pattern") {
345            if let Some(p) = build_pattern(&fm, &body) {
346                imp.patterns.push(p);
347            }
348            continue;
349        }
350
351        let category = concept_category(&fm);
352        let key = concept_key(&fm);
353        let from = KnowledgeNodeRef::new(&category, &key);
354        imp.facts.push(build_fact(&fm, &body, category, key));
355        imp.edges.extend(parse_relations(&body, &from));
356    }
357    Ok(imp)
358}
359
360/// Non-fatal conformance checks. Returns warnings only — OKF's own tooling
361/// treats these as advisory, and a partially-malformed bundle should still
362/// import what it can.
363pub fn lint_okf_bundle(dir: &Path) -> Vec<String> {
364    let mut warnings = Vec::new();
365    if !dir.is_dir() {
366        warnings.push(format!("not a directory: {}", dir.display()));
367        return warnings;
368    }
369    for path in concept_files(dir) {
370        let rel = path
371            .strip_prefix(dir)
372            .unwrap_or(&path)
373            .to_string_lossy()
374            .to_string();
375        let Ok(content) = std::fs::read_to_string(&path) else {
376            warnings.push(format!("{rel}: unreadable"));
377            continue;
378        };
379        let Some((fm_str, body)) = split_frontmatter(&content) else {
380            warnings.push(format!("{rel}: missing YAML frontmatter"));
381            continue;
382        };
383        let fm = parse_frontmatter_map(&fm_str);
384        if !fm.contains_key("type") {
385            warnings.push(format!("{rel}: missing required `type` field"));
386        }
387        if split_body_content(&body).is_empty() {
388            warnings.push(format!("{rel}: empty concept body"));
389        }
390    }
391    warnings
392}
393
394fn build_fact(fm: &Map<String, Value>, body: &str, category: String, key: String) -> KnowledgeFact {
395    let type_str = get_str(fm, "type").unwrap_or_else(|| "fact".to_string());
396    let content = split_body_content(body);
397    let value = if content.is_empty() {
398        get_str(fm, "description").unwrap_or_default()
399    } else {
400        content
401    };
402    let archetype = get_str(fm, "leanctx_archetype").map_or_else(
403        || KnowledgeArchetype::from_type_str(&type_str),
404        |s| KnowledgeArchetype::from_type_str(&s),
405    );
406    let confidence = fm
407        .get("leanctx_confidence")
408        .and_then(Value::as_f64)
409        .map_or(0.8, |v| v as f32);
410    let source_session =
411        get_str(fm, "leanctx_source_session").unwrap_or_else(|| "okf-import".to_string());
412    let created_at = get_dt(fm, "leanctx_created_at").unwrap_or_else(Utc::now);
413    let last_confirmed = get_dt(fm, "leanctx_last_confirmed")
414        .or_else(|| get_dt(fm, "timestamp"))
415        .unwrap_or(created_at);
416    let valid_from = get_dt(fm, "leanctx_valid_from").or(Some(created_at));
417    let valid_until = get_dt(fm, "leanctx_valid_until");
418    let confirmation_count = fm
419        .get("leanctx_confirmation_count")
420        .and_then(Value::as_u64)
421        .unwrap_or(1) as u32;
422    let revision_count = fm
423        .get("leanctx_revision_count")
424        .and_then(Value::as_u64)
425        .unwrap_or(0) as u32;
426    let sensitivity = get_str(fm, "leanctx_sensitivity")
427        .and_then(|s| serde_json::from_value(Value::String(s)).ok())
428        .unwrap_or_else(|| sensitivity::classify_content(&value));
429
430    KnowledgeFact {
431        anchors: Vec::new(),
432        review: None,
433        capture: Default::default(),
434        origin: super::source_view::imported_origin(
435            &fm.get("leanctx_origin")
436                .and_then(|value| serde_json::from_value(value.clone()).ok())
437                .unwrap_or_default(),
438        ),
439        category,
440        key,
441        value,
442        source_session,
443        confidence,
444        created_at,
445        last_confirmed,
446        retrieval_count: 0,
447        last_retrieved: None,
448        valid_from,
449        valid_until,
450        supersedes: get_str(fm, "leanctx_supersedes"),
451        confirmation_count,
452        feedback_up: 0,
453        feedback_down: 0,
454        last_feedback: None,
455        privacy: FactPrivacy::default(),
456        sensitivity,
457        imported_from: Some("okf".to_string()),
458        archetype,
459        fidelity: None,
460        revision_count,
461        status: None,
462        validity: None,
463    }
464}
465
466fn build_pattern(fm: &Map<String, Value>, body: &str) -> Option<ProjectPattern> {
467    let pattern_type = get_str(fm, "title")?;
468    let content = split_body_content(body);
469    let description = if content.is_empty() {
470        get_str(fm, "description").unwrap_or_default()
471    } else {
472        content
473    };
474    let examples = fm
475        .get("leanctx_examples")
476        .and_then(Value::as_array)
477        .map(|arr| {
478            arr.iter()
479                .filter_map(|v| v.as_str().map(String::from))
480                .collect()
481        })
482        .unwrap_or_default();
483    Some(ProjectPattern {
484        pattern_type,
485        description,
486        examples,
487        source_session: get_str(fm, "leanctx_source_session")
488            .unwrap_or_else(|| "okf-import".to_string()),
489        created_at: get_dt(fm, "leanctx_created_at").unwrap_or_else(Utc::now),
490    })
491}
492
493fn parse_relations(body: &str, from: &KnowledgeNodeRef) -> Vec<OkfEdge> {
494    let mut edges = Vec::new();
495    let mut in_relations = false;
496    for line in body.lines() {
497        let t = line.trim();
498        if t.eq_ignore_ascii_case("## relations") {
499            in_relations = true;
500            continue;
501        }
502        if in_relations && t.starts_with("## ") {
503            break;
504        }
505        if !in_relations {
506            continue;
507        }
508        let Some(rest) = t.strip_prefix("- ") else {
509            continue;
510        };
511        let Some((kind_str, link_part)) = rest.split_once(':') else {
512            continue;
513        };
514        let Some(kind) = KnowledgeEdgeKind::parse(kind_str.trim()) else {
515            continue;
516        };
517        // Extract the `[label]` target id from `[label](path)`.
518        let label = link_part.trim().trim_start_matches('[');
519        let Some(end) = label.find(']') else {
520            continue;
521        };
522        if let Some(to) = parse_node_ref(&label[..end]) {
523            edges.push(OkfEdge {
524                from: from.clone(),
525                to,
526                kind,
527            });
528        }
529    }
530    edges
531}
532
533// ---------------------------------------------------------------------------
534// Frontmatter helpers
535// ---------------------------------------------------------------------------
536
537/// Emits YAML frontmatter deterministically. Scalars are rendered via
538/// `serde_json` (JSON is a YAML 1.2 subset, so quoted strings / numbers / bools
539/// are valid YAML and unambiguous); arrays use block style for readability.
540fn emit_frontmatter(pairs: &[(String, Value)]) -> String {
541    let mut s = String::from("---\n");
542    for (k, v) in pairs {
543        match v {
544            Value::Array(items) if !items.is_empty() => {
545                s.push_str(k);
546                s.push_str(":\n");
547                for it in items {
548                    s.push_str("  - ");
549                    s.push_str(&serde_json::to_string(it).unwrap_or_else(|_| "null".into()));
550                    s.push('\n');
551                }
552            }
553            Value::Array(_) => {
554                s.push_str(k);
555                s.push_str(": []\n");
556            }
557            _ => {
558                s.push_str(k);
559                s.push_str(": ");
560                s.push_str(&serde_json::to_string(v).unwrap_or_else(|_| "null".into()));
561                s.push('\n');
562            }
563        }
564    }
565    s.push_str("---\n");
566    s
567}
568
569fn parse_frontmatter_map(fm: &str) -> Map<String, Value> {
570    yaml_serde::from_str::<Value>(fm)
571        .ok()
572        .and_then(|v| v.as_object().cloned())
573        .unwrap_or_default()
574}
575
576/// Splits a Markdown document into `(frontmatter, body)` if it opens with a
577/// `---` fenced YAML block. Returns `None` when there is no frontmatter.
578fn split_frontmatter(content: &str) -> Option<(String, String)> {
579    let content = content.strip_prefix('\u{feff}').unwrap_or(content);
580    let after = content.strip_prefix("---")?;
581    let after = after
582        .strip_prefix("\r\n")
583        .or_else(|| after.strip_prefix('\n'))?;
584
585    let mut fm = String::new();
586    let mut body = String::new();
587    let mut closed = false;
588    let mut in_body = false;
589    for line in after.lines() {
590        if in_body {
591            body.push_str(line);
592            body.push('\n');
593        } else if line.trim_end() == "---" {
594            in_body = true;
595            closed = true;
596        } else {
597            fm.push_str(line);
598            fm.push('\n');
599        }
600    }
601    closed.then_some((fm, body))
602}
603
604/// The concept body with any `## Relations` (and later sections) stripped —
605/// i.e. the actual knowledge content.
606fn split_body_content(body: &str) -> String {
607    let mut out = String::new();
608    for line in body.lines() {
609        let t = line.trim();
610        if t.eq_ignore_ascii_case("## relations") {
611            break;
612        }
613        out.push_str(line);
614        out.push('\n');
615    }
616    out.trim().to_string()
617}
618
619fn concept_category(fm: &Map<String, Value>) -> String {
620    get_str(fm, "leanctx_category")
621        .or_else(|| get_str_array_first(fm, "tags"))
622        .unwrap_or_else(|| "imported".to_string())
623}
624
625fn concept_key(fm: &Map<String, Value>) -> String {
626    get_str(fm, "leanctx_key")
627        .or_else(|| get_str(fm, "title"))
628        .unwrap_or_else(|| "concept".to_string())
629}
630
631fn get_str(fm: &Map<String, Value>, key: &str) -> Option<String> {
632    fm.get(key).and_then(Value::as_str).map(str::to_string)
633}
634
635fn get_str_array_first(fm: &Map<String, Value>, key: &str) -> Option<String> {
636    fm.get(key)?
637        .as_array()?
638        .iter()
639        .find_map(|v| v.as_str().map(String::from))
640}
641
642fn get_dt(fm: &Map<String, Value>, key: &str) -> Option<DateTime<Utc>> {
643    let s = fm.get(key).and_then(Value::as_str)?;
644    DateTime::parse_from_rfc3339(s)
645        .ok()
646        .map(|dt| dt.with_timezone(&Utc))
647}
648
649// ---------------------------------------------------------------------------
650// Slugs & paths
651// ---------------------------------------------------------------------------
652
653fn first_line(s: &str) -> String {
654    s.lines()
655        .find(|l| !l.trim().is_empty())
656        .unwrap_or("")
657        .trim()
658        .to_string()
659}
660
661/// Deterministic, filesystem-safe slug: lowercase alphanumerics, single dashes
662/// for separators, trimmed, capped.
663fn slug_like(s: &str) -> String {
664    let mut out = String::new();
665    for ch in s.chars() {
666        if out.len() >= 60 {
667            break;
668        }
669        if ch.is_ascii_alphanumeric() {
670            out.push(ch.to_ascii_lowercase());
671        } else if !out.ends_with('-') && !out.is_empty() {
672            out.push('-');
673        }
674    }
675    out.trim_matches('-').to_string()
676}
677
678fn dir_slug(category: &str) -> String {
679    let s = slug_like(category);
680    if s.is_empty() { "misc".to_string() } else { s }
681}
682
683/// A slug unique within `dir`. On collision (two keys slugging alike) a short
684/// BLAKE3 digest of the full key is appended — stable regardless of iteration
685/// order, so the bundle stays deterministic.
686fn unique_slug(dir: &str, key: &str, used: &mut HashSet<(String, String)>) -> String {
687    let base = {
688        let s = slug_like(key);
689        if s.is_empty() { "fact".to_string() } else { s }
690    };
691    if used.insert((dir.to_string(), base.clone())) {
692        return base;
693    }
694    let suffix = &blake3::hash(key.as_bytes()).to_hex()[..8];
695    let cand = format!("{base}-{suffix}");
696    used.insert((dir.to_string(), cand.clone()));
697    cand
698}
699
700fn relative_link(from_dir: &str, to: &ConceptLoc) -> String {
701    if from_dir == to.dir {
702        format!("{}.md", to.file)
703    } else {
704        format!("../{}/{}.md", to.dir, to.file)
705    }
706}
707
708/// All concept `*.md` files in the bundle, sorted, excluding reserved
709/// `index.md` / `log.md`.
710fn concept_files(dir: &Path) -> Vec<std::path::PathBuf> {
711    let mut files: Vec<std::path::PathBuf> = WalkDir::new(dir)
712        .sort_by_file_name()
713        .into_iter()
714        .filter_map(Result::ok)
715        .filter(|e| e.file_type().is_file())
716        .map(walkdir::DirEntry::into_path)
717        .filter(|p| p.extension().is_some_and(|e| e == "md"))
718        .filter(|p| {
719            let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
720            name != INDEX_FILE && name != LOG_FILE
721        })
722        .collect();
723    files.sort();
724    files
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::core::knowledge_relations::KnowledgeRelationGraph;
731
732    fn fact(
733        category: &str,
734        key: &str,
735        value: &str,
736        archetype: KnowledgeArchetype,
737    ) -> KnowledgeFact {
738        let now = Utc::now();
739        KnowledgeFact {
740            anchors: Vec::new(),
741            review: None,
742            capture: Default::default(),
743            origin: crate::core::knowledge::FactOrigin::Local,
744            category: category.into(),
745            key: key.into(),
746            value: value.into(),
747            source_session: "s1".into(),
748            confidence: 0.9,
749            created_at: now,
750            last_confirmed: now,
751            retrieval_count: 0,
752            last_retrieved: None,
753            valid_from: Some(now),
754            valid_until: None,
755            supersedes: None,
756            confirmation_count: 1,
757            feedback_up: 0,
758            feedback_down: 0,
759            last_feedback: None,
760            privacy: FactPrivacy::default(),
761            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
762            imported_from: None,
763            archetype,
764            fidelity: None,
765            revision_count: 1,
766            status: None,
767            validity: None,
768        }
769    }
770
771    fn sample_snapshot() -> KnowledgeSnapshot {
772        let facts = vec![
773            fact(
774                "architecture",
775                "auth",
776                "Auth uses JWT RS256 tokens verified against Redis sessions.",
777                KnowledgeArchetype::Architecture,
778            ),
779            fact(
780                "architecture",
781                "db",
782                "PostgreSQL 16 with pgvector for the primary datastore.",
783                KnowledgeArchetype::Architecture,
784            ),
785        ];
786        let mut graph = KnowledgeRelationGraph::new("hash");
787        graph.upsert_edge(
788            KnowledgeNodeRef::new("architecture", "auth"),
789            KnowledgeNodeRef::new("architecture", "db"),
790            KnowledgeEdgeKind::DependsOn,
791            "s1",
792        );
793        KnowledgeSnapshot {
794            project_root: "/tmp/proj".into(),
795            project_hash: "hash".into(),
796            facts,
797            patterns: Vec::new(),
798            insights: Vec::new(),
799            relations: graph.edges,
800            task_id: None,
801            policy_version: None,
802            knowledge_refs: Vec::new(),
803            snapshot_hash: None,
804        }
805    }
806
807    fn key_set(facts: &[KnowledgeFact]) -> HashSet<(String, String, String, String)> {
808        facts
809            .iter()
810            .map(|f| {
811                (
812                    f.category.clone(),
813                    f.key.clone(),
814                    f.value.clone(),
815                    f.archetype.as_type_str().to_string(),
816                )
817            })
818            .collect()
819    }
820
821    #[test]
822    fn round_trip_preserves_facts_and_relations() {
823        let snap = sample_snapshot();
824        let bundle = to_okf_bundle(&snap);
825        let dir = tempfile::tempdir().unwrap();
826        write_okf_bundle(dir.path(), &bundle).unwrap();
827
828        let imported = from_okf_dir(dir.path()).unwrap();
829        assert_eq!(
830            key_set(&imported.facts),
831            key_set(&snap.facts),
832            "current facts (category/key/value/archetype) survive the round-trip"
833        );
834
835        assert_eq!(imported.edges.len(), 1, "the depends_on relation survives");
836        let e = &imported.edges[0];
837        assert_eq!(e.from, KnowledgeNodeRef::new("architecture", "auth"));
838        assert_eq!(e.to, KnowledgeNodeRef::new("architecture", "db"));
839        assert_eq!(e.kind, KnowledgeEdgeKind::DependsOn);
840    }
841
842    #[test]
843    fn export_is_byte_deterministic() {
844        let snap = sample_snapshot();
845        assert_eq!(
846            to_okf_bundle(&snap),
847            to_okf_bundle(&snap),
848            "two exports of the same snapshot are byte-identical (#498)"
849        );
850    }
851
852    #[test]
853    fn okf_concepts_match_current_facts() {
854        // The shared-core guarantee: the OKF rendering derives exactly the
855        // snapshot's current facts — one concept file per current fact.
856        let snap = sample_snapshot();
857        let bundle = to_okf_bundle(&snap);
858        let concept_count = bundle
859            .keys()
860            .filter(|k| *k != INDEX_FILE && *k != LOG_FILE && !k.starts_with(PATTERNS_DIR))
861            .count();
862        assert_eq!(concept_count, snap.current_facts().len());
863    }
864
865    #[test]
866    fn foreign_bundle_needs_only_type() {
867        let dir = tempfile::tempdir().unwrap();
868        std::fs::write(
869            dir.path().join("note.md"),
870            "---\ntype: architecture\n---\n\nWe run everything on Kubernetes.\n",
871        )
872        .unwrap();
873
874        let imp = from_okf_dir(dir.path()).unwrap();
875        assert_eq!(
876            imp.facts.len(),
877            1,
878            "a type-only concept imports as one fact"
879        );
880        let f = &imp.facts[0];
881        assert_eq!(f.archetype, KnowledgeArchetype::Architecture);
882        assert_eq!(f.value, "We run everything on Kubernetes.");
883        assert_eq!(
884            f.key, "concept",
885            "no title/leanctx_key falls back to a default"
886        );
887    }
888
889    #[test]
890    fn unknown_frontmatter_keys_survive_a_parse_emit_cycle() {
891        // OKF lets producers add arbitrary keys and requires consumers to keep
892        // them. Our frontmatter emitter/parser round-trips an unknown key.
893        let src = "custom_producer_key: \"keep me\"\ntype: \"fact\"\n";
894        let map = parse_frontmatter_map(src);
895        let pairs: Vec<(String, Value)> = map.into_iter().collect();
896        let emitted = emit_frontmatter(&pairs);
897        let reparsed = parse_frontmatter_map(
898            emitted
899                .trim_start_matches("---\n")
900                .trim_end_matches("---\n"),
901        );
902        assert_eq!(
903            reparsed.get("custom_producer_key").and_then(Value::as_str),
904            Some("keep me")
905        );
906    }
907
908    #[test]
909    fn lint_reports_warnings_never_panics() {
910        let dir = tempfile::tempdir().unwrap();
911        std::fs::write(dir.path().join("bad.md"), "no frontmatter here\n").unwrap();
912        std::fs::write(dir.path().join("ok.md"), "---\ntype: fact\n---\n\nbody\n").unwrap();
913        let warnings = lint_okf_bundle(dir.path());
914        assert!(
915            warnings.iter().any(|w| w.contains("bad.md")),
916            "the malformed file is flagged: {warnings:?}"
917        );
918        // The good file still imports despite the bad one.
919        assert_eq!(from_okf_dir(dir.path()).unwrap().facts.len(), 1);
920    }
921
922    #[test]
923    fn patterns_round_trip_with_examples() {
924        let mut snap = sample_snapshot();
925        snap.patterns.push(ProjectPattern {
926            pattern_type: "naming".into(),
927            description: "snake_case for functions".into(),
928            examples: vec!["get_user()".into()],
929            source_session: "s1".into(),
930            created_at: Utc::now(),
931        });
932        let bundle = to_okf_bundle(&snap);
933        let dir = tempfile::tempdir().unwrap();
934        write_okf_bundle(dir.path(), &bundle).unwrap();
935
936        let imp = from_okf_dir(dir.path()).unwrap();
937        assert_eq!(imp.patterns.len(), 1);
938        assert_eq!(imp.patterns[0].pattern_type, "naming");
939        assert_eq!(imp.patterns[0].examples, vec!["get_user()".to_string()]);
940    }
941
942    #[cfg(unix)]
943    #[test]
944    fn write_okf_bundle_refuses_symlinked_components() {
945        use std::os::unix::fs::symlink;
946        let tmp = tempfile::tempdir().unwrap();
947        let outside = tmp.path().join("outside");
948        std::fs::create_dir_all(&outside).unwrap();
949        let mut bundle = OkfBundle::new();
950        bundle.insert("concepts/a.md".to_string(), "# a\n".to_string());
951
952        // A symlinked category directory pointing outside the export.
953        let dir = tmp.path().join("export1");
954        std::fs::create_dir_all(&dir).unwrap();
955        symlink(&outside, dir.join("concepts")).unwrap();
956        let err = write_okf_bundle(&dir, &bundle).unwrap_err();
957        assert!(err.contains("symlink"), "{err}");
958        assert!(!outside.join("a.md").exists());
959
960        // A symlinked bundle file pointing at a file outside the export.
961        let victim = tmp.path().join("victim.md");
962        std::fs::write(&victim, "keep").unwrap();
963        let dir = tmp.path().join("export2");
964        std::fs::create_dir_all(dir.join("concepts")).unwrap();
965        symlink(&victim, dir.join("concepts/a.md")).unwrap();
966        assert!(write_okf_bundle(&dir, &bundle).is_err());
967        assert_eq!(std::fs::read_to_string(&victim).unwrap(), "keep");
968
969        // A clean export still writes.
970        let dir = tmp.path().join("export3");
971        write_okf_bundle(&dir, &bundle).unwrap();
972        assert_eq!(
973            std::fs::read_to_string(dir.join("concepts/a.md")).unwrap(),
974            "# a\n"
975        );
976    }
977}