1use 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
44const INDEX_FILE: &str = "index.md";
47const LOG_FILE: &str = "log.md";
48const PATTERNS_DIR: &str = "patterns";
50
51pub type OkfBundle = BTreeMap<String, String>;
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct OkfEdge {
58 pub from: KnowledgeNodeRef,
59 pub to: KnowledgeNodeRef,
60 pub kind: KnowledgeEdgeKind,
61}
62
63#[derive(Debug, Default)]
65pub struct OkfImport {
66 pub facts: Vec<KnowledgeFact>,
67 pub patterns: Vec<ProjectPattern>,
68 pub edges: Vec<OkfEdge>,
69}
70
71struct ConceptLoc {
73 dir: String,
74 file: String,
75}
76
77pub 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 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 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 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 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
149pub fn write_okf_bundle(dir: &Path, bundle: &OkfBundle) -> Result<(), String> {
151 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
152 for (rel, contents) in bundle {
153 let path = dir.join(rel);
154 if let Some(parent) = path.parent() {
155 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
156 }
157 std::fs::write(&path, contents).map_err(|e| e.to_string())?;
158 }
159 Ok(())
160}
161
162fn render_concept(f: &KnowledgeFact, rel_lines: &[String]) -> String {
163 let mut out = emit_frontmatter(&concept_frontmatter(f));
164 out.push('\n');
165 out.push_str(f.value.trim_end());
166 out.push('\n');
167 if !rel_lines.is_empty() {
168 out.push_str("\n## Relations\n\n");
169 for line in rel_lines {
170 out.push_str(line);
171 out.push('\n');
172 }
173 }
174 out
175}
176
177fn clean_f32(x: f32) -> Value {
183 let parsed: f64 = format!("{x}").parse().unwrap_or_else(|_| f64::from(x));
184 serde_json::Number::from_f64(parsed).map_or(Value::Null, Value::Number)
185}
186
187fn concept_frontmatter(f: &KnowledgeFact) -> Vec<(String, Value)> {
188 let mut pairs: Vec<(String, Value)> = Vec::new();
189 pairs.push(("type".into(), Value::from(f.archetype.as_type_str())));
190 pairs.push(("title".into(), Value::from(f.key.clone())));
191 let desc = first_line(&f.value);
192 if !desc.is_empty() {
193 pairs.push(("description".into(), Value::from(desc)));
194 }
195 pairs.push(("tags".into(), Value::from(vec![f.category.clone()])));
196 pairs.push((
197 "timestamp".into(),
198 Value::from(f.last_confirmed.to_rfc3339()),
199 ));
200
201 let mut extra: BTreeMap<String, Value> = BTreeMap::new();
202 extra.insert(
203 "leanctx_origin".into(),
204 serde_json::to_value(&f.origin).unwrap_or(Value::Null),
205 );
206 extra.insert("leanctx_archetype".into(), f.archetype.as_type_str().into());
207 extra.insert("leanctx_category".into(), f.category.clone().into());
208 extra.insert("leanctx_confidence".into(), clean_f32(f.confidence));
209 extra.insert(
210 "leanctx_confirmation_count".into(),
211 f.confirmation_count.into(),
212 );
213 extra.insert(
214 "leanctx_created_at".into(),
215 f.created_at.to_rfc3339().into(),
216 );
217 extra.insert("leanctx_key".into(), f.key.clone().into());
218 extra.insert(
219 "leanctx_last_confirmed".into(),
220 f.last_confirmed.to_rfc3339().into(),
221 );
222 extra.insert("leanctx_revision_count".into(), f.revision_count.into());
223 extra.insert(
224 "leanctx_sensitivity".into(),
225 serde_json::to_value(f.sensitivity).unwrap_or_else(|_| "public".into()),
226 );
227 extra.insert(
228 "leanctx_source_session".into(),
229 f.source_session.clone().into(),
230 );
231 if let Some(vf) = f.valid_from {
232 extra.insert("leanctx_valid_from".into(), vf.to_rfc3339().into());
233 }
234 if let Some(vu) = f.valid_until {
235 extra.insert("leanctx_valid_until".into(), vu.to_rfc3339().into());
236 }
237 if let Some(s) = &f.supersedes {
238 extra.insert("leanctx_supersedes".into(), s.clone().into());
239 }
240 pairs.extend(extra);
241 pairs
242}
243
244fn render_pattern(p: &ProjectPattern) -> String {
245 let mut pairs: Vec<(String, Value)> = Vec::new();
246 pairs.push(("type".into(), Value::from("pattern")));
247 pairs.push(("title".into(), Value::from(p.pattern_type.clone())));
248 let desc = first_line(&p.description);
249 if !desc.is_empty() {
250 pairs.push(("description".into(), Value::from(desc)));
251 }
252 let mut extra: BTreeMap<String, Value> = BTreeMap::new();
253 extra.insert(
254 "leanctx_created_at".into(),
255 p.created_at.to_rfc3339().into(),
256 );
257 extra.insert("leanctx_examples".into(), Value::from(p.examples.clone()));
258 extra.insert("leanctx_kind".into(), "pattern".into());
259 extra.insert(
260 "leanctx_source_session".into(),
261 p.source_session.clone().into(),
262 );
263 pairs.extend(extra);
264
265 let mut out = emit_frontmatter(&pairs);
266 out.push('\n');
267 out.push_str(p.description.trim_end());
268 out.push('\n');
269 if !p.examples.is_empty() {
270 out.push_str("\n## Examples\n\n");
271 for ex in &p.examples {
272 out.push_str("- ");
273 out.push_str(ex.trim());
274 out.push('\n');
275 }
276 }
277 out
278}
279
280fn render_index(facts: &[&KnowledgeFact], patterns: &[ProjectPattern]) -> String {
281 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
282 for f in facts {
283 *counts.entry(f.category.clone()).or_insert(0) += 1;
284 }
285 let mut out = String::from(
286 "# Knowledge Index\n\nOpen Knowledge Format (OKF) bundle exported by lean-ctx.\n\n## Concepts by category\n\n",
287 );
288 if counts.is_empty() {
289 out.push_str("_none_\n");
290 } else {
291 for (cat, n) in &counts {
292 out.push_str(&format!("- {} ({})\n", dir_slug(cat), n));
293 }
294 }
295 out.push_str(&format!("\n## Patterns ({})\n", patterns.len()));
296 out
297}
298
299fn render_log(snapshot: &KnowledgeSnapshot) -> String {
300 let mut insights = snapshot.insights.clone();
301 insights.sort_by(|a, b| {
302 a.timestamp
303 .cmp(&b.timestamp)
304 .then_with(|| a.summary.cmp(&b.summary))
305 });
306 let mut out = String::from("# Change Log\n\n");
307 for i in &insights {
308 out.push_str(&format!("## {}\n\n", i.timestamp.to_rfc3339()));
309 out.push_str(i.summary.trim());
310 out.push('\n');
311 if !i.from_sessions.is_empty() {
312 out.push_str(&format!("\n_sessions: {}_\n", i.from_sessions.join(", ")));
313 }
314 out.push('\n');
315 }
316 out
317}
318
319pub fn from_okf_dir(dir: &Path) -> Result<OkfImport, String> {
327 if !dir.is_dir() {
328 return Err(format!("not a directory: {}", dir.display()));
329 }
330 let mut imp = OkfImport::default();
331 for path in concept_files(dir) {
332 let Ok(content) = std::fs::read_to_string(&path) else {
333 continue;
334 };
335 let Some((fm_str, body)) = split_frontmatter(&content) else {
336 continue;
337 };
338 let fm = parse_frontmatter_map(&fm_str);
339 if !fm.contains_key("type") {
340 continue;
341 }
342
343 if fm.get("leanctx_kind").and_then(Value::as_str) == Some("pattern") {
344 if let Some(p) = build_pattern(&fm, &body) {
345 imp.patterns.push(p);
346 }
347 continue;
348 }
349
350 let category = concept_category(&fm);
351 let key = concept_key(&fm);
352 let from = KnowledgeNodeRef::new(&category, &key);
353 imp.facts.push(build_fact(&fm, &body, category, key));
354 imp.edges.extend(parse_relations(&body, &from));
355 }
356 Ok(imp)
357}
358
359pub fn lint_okf_bundle(dir: &Path) -> Vec<String> {
363 let mut warnings = Vec::new();
364 if !dir.is_dir() {
365 warnings.push(format!("not a directory: {}", dir.display()));
366 return warnings;
367 }
368 for path in concept_files(dir) {
369 let rel = path
370 .strip_prefix(dir)
371 .unwrap_or(&path)
372 .to_string_lossy()
373 .to_string();
374 let Ok(content) = std::fs::read_to_string(&path) else {
375 warnings.push(format!("{rel}: unreadable"));
376 continue;
377 };
378 let Some((fm_str, body)) = split_frontmatter(&content) else {
379 warnings.push(format!("{rel}: missing YAML frontmatter"));
380 continue;
381 };
382 let fm = parse_frontmatter_map(&fm_str);
383 if !fm.contains_key("type") {
384 warnings.push(format!("{rel}: missing required `type` field"));
385 }
386 if split_body_content(&body).is_empty() {
387 warnings.push(format!("{rel}: empty concept body"));
388 }
389 }
390 warnings
391}
392
393fn build_fact(fm: &Map<String, Value>, body: &str, category: String, key: String) -> KnowledgeFact {
394 let type_str = get_str(fm, "type").unwrap_or_else(|| "fact".to_string());
395 let content = split_body_content(body);
396 let value = if content.is_empty() {
397 get_str(fm, "description").unwrap_or_default()
398 } else {
399 content
400 };
401 let archetype = get_str(fm, "leanctx_archetype").map_or_else(
402 || KnowledgeArchetype::from_type_str(&type_str),
403 |s| KnowledgeArchetype::from_type_str(&s),
404 );
405 let confidence = fm
406 .get("leanctx_confidence")
407 .and_then(Value::as_f64)
408 .map_or(0.8, |v| v as f32);
409 let source_session =
410 get_str(fm, "leanctx_source_session").unwrap_or_else(|| "okf-import".to_string());
411 let created_at = get_dt(fm, "leanctx_created_at").unwrap_or_else(Utc::now);
412 let last_confirmed = get_dt(fm, "leanctx_last_confirmed")
413 .or_else(|| get_dt(fm, "timestamp"))
414 .unwrap_or(created_at);
415 let valid_from = get_dt(fm, "leanctx_valid_from").or(Some(created_at));
416 let valid_until = get_dt(fm, "leanctx_valid_until");
417 let confirmation_count = fm
418 .get("leanctx_confirmation_count")
419 .and_then(Value::as_u64)
420 .unwrap_or(1) as u32;
421 let revision_count = fm
422 .get("leanctx_revision_count")
423 .and_then(Value::as_u64)
424 .unwrap_or(0) as u32;
425 let sensitivity = get_str(fm, "leanctx_sensitivity")
426 .and_then(|s| serde_json::from_value(Value::String(s)).ok())
427 .unwrap_or_else(|| sensitivity::classify_content(&value));
428
429 KnowledgeFact {
430 anchors: Vec::new(),
431 review: None,
432 capture: Default::default(),
433 origin: super::source_view::imported_origin(
434 &fm.get("leanctx_origin")
435 .and_then(|value| serde_json::from_value(value.clone()).ok())
436 .unwrap_or_default(),
437 ),
438 category,
439 key,
440 value,
441 source_session,
442 confidence,
443 created_at,
444 last_confirmed,
445 retrieval_count: 0,
446 last_retrieved: None,
447 valid_from,
448 valid_until,
449 supersedes: get_str(fm, "leanctx_supersedes"),
450 confirmation_count,
451 feedback_up: 0,
452 feedback_down: 0,
453 last_feedback: None,
454 privacy: FactPrivacy::default(),
455 sensitivity,
456 imported_from: Some("okf".to_string()),
457 archetype,
458 fidelity: None,
459 revision_count,
460 status: None,
461 validity: None,
462 }
463}
464
465fn build_pattern(fm: &Map<String, Value>, body: &str) -> Option<ProjectPattern> {
466 let pattern_type = get_str(fm, "title")?;
467 let content = split_body_content(body);
468 let description = if content.is_empty() {
469 get_str(fm, "description").unwrap_or_default()
470 } else {
471 content
472 };
473 let examples = fm
474 .get("leanctx_examples")
475 .and_then(Value::as_array)
476 .map(|arr| {
477 arr.iter()
478 .filter_map(|v| v.as_str().map(String::from))
479 .collect()
480 })
481 .unwrap_or_default();
482 Some(ProjectPattern {
483 pattern_type,
484 description,
485 examples,
486 source_session: get_str(fm, "leanctx_source_session")
487 .unwrap_or_else(|| "okf-import".to_string()),
488 created_at: get_dt(fm, "leanctx_created_at").unwrap_or_else(Utc::now),
489 })
490}
491
492fn parse_relations(body: &str, from: &KnowledgeNodeRef) -> Vec<OkfEdge> {
493 let mut edges = Vec::new();
494 let mut in_relations = false;
495 for line in body.lines() {
496 let t = line.trim();
497 if t.eq_ignore_ascii_case("## relations") {
498 in_relations = true;
499 continue;
500 }
501 if in_relations && t.starts_with("## ") {
502 break;
503 }
504 if !in_relations {
505 continue;
506 }
507 let Some(rest) = t.strip_prefix("- ") else {
508 continue;
509 };
510 let Some((kind_str, link_part)) = rest.split_once(':') else {
511 continue;
512 };
513 let Some(kind) = KnowledgeEdgeKind::parse(kind_str.trim()) else {
514 continue;
515 };
516 let label = link_part.trim().trim_start_matches('[');
518 let Some(end) = label.find(']') else {
519 continue;
520 };
521 if let Some(to) = parse_node_ref(&label[..end]) {
522 edges.push(OkfEdge {
523 from: from.clone(),
524 to,
525 kind,
526 });
527 }
528 }
529 edges
530}
531
532fn emit_frontmatter(pairs: &[(String, Value)]) -> String {
540 let mut s = String::from("---\n");
541 for (k, v) in pairs {
542 match v {
543 Value::Array(items) if !items.is_empty() => {
544 s.push_str(k);
545 s.push_str(":\n");
546 for it in items {
547 s.push_str(" - ");
548 s.push_str(&serde_json::to_string(it).unwrap_or_else(|_| "null".into()));
549 s.push('\n');
550 }
551 }
552 Value::Array(_) => {
553 s.push_str(k);
554 s.push_str(": []\n");
555 }
556 _ => {
557 s.push_str(k);
558 s.push_str(": ");
559 s.push_str(&serde_json::to_string(v).unwrap_or_else(|_| "null".into()));
560 s.push('\n');
561 }
562 }
563 }
564 s.push_str("---\n");
565 s
566}
567
568fn parse_frontmatter_map(fm: &str) -> Map<String, Value> {
569 yaml_serde::from_str::<Value>(fm)
570 .ok()
571 .and_then(|v| v.as_object().cloned())
572 .unwrap_or_default()
573}
574
575fn split_frontmatter(content: &str) -> Option<(String, String)> {
578 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
579 let after = content.strip_prefix("---")?;
580 let after = after
581 .strip_prefix("\r\n")
582 .or_else(|| after.strip_prefix('\n'))?;
583
584 let mut fm = String::new();
585 let mut body = String::new();
586 let mut closed = false;
587 let mut in_body = false;
588 for line in after.lines() {
589 if in_body {
590 body.push_str(line);
591 body.push('\n');
592 } else if line.trim_end() == "---" {
593 in_body = true;
594 closed = true;
595 } else {
596 fm.push_str(line);
597 fm.push('\n');
598 }
599 }
600 closed.then_some((fm, body))
601}
602
603fn split_body_content(body: &str) -> String {
606 let mut out = String::new();
607 for line in body.lines() {
608 let t = line.trim();
609 if t.eq_ignore_ascii_case("## relations") {
610 break;
611 }
612 out.push_str(line);
613 out.push('\n');
614 }
615 out.trim().to_string()
616}
617
618fn concept_category(fm: &Map<String, Value>) -> String {
619 get_str(fm, "leanctx_category")
620 .or_else(|| get_str_array_first(fm, "tags"))
621 .unwrap_or_else(|| "imported".to_string())
622}
623
624fn concept_key(fm: &Map<String, Value>) -> String {
625 get_str(fm, "leanctx_key")
626 .or_else(|| get_str(fm, "title"))
627 .unwrap_or_else(|| "concept".to_string())
628}
629
630fn get_str(fm: &Map<String, Value>, key: &str) -> Option<String> {
631 fm.get(key).and_then(Value::as_str).map(str::to_string)
632}
633
634fn get_str_array_first(fm: &Map<String, Value>, key: &str) -> Option<String> {
635 fm.get(key)?
636 .as_array()?
637 .iter()
638 .find_map(|v| v.as_str().map(String::from))
639}
640
641fn get_dt(fm: &Map<String, Value>, key: &str) -> Option<DateTime<Utc>> {
642 let s = fm.get(key).and_then(Value::as_str)?;
643 DateTime::parse_from_rfc3339(s)
644 .ok()
645 .map(|dt| dt.with_timezone(&Utc))
646}
647
648fn first_line(s: &str) -> String {
653 s.lines()
654 .find(|l| !l.trim().is_empty())
655 .unwrap_or("")
656 .trim()
657 .to_string()
658}
659
660fn slug_like(s: &str) -> String {
663 let mut out = String::new();
664 for ch in s.chars() {
665 if out.len() >= 60 {
666 break;
667 }
668 if ch.is_ascii_alphanumeric() {
669 out.push(ch.to_ascii_lowercase());
670 } else if !out.ends_with('-') && !out.is_empty() {
671 out.push('-');
672 }
673 }
674 out.trim_matches('-').to_string()
675}
676
677fn dir_slug(category: &str) -> String {
678 let s = slug_like(category);
679 if s.is_empty() { "misc".to_string() } else { s }
680}
681
682fn unique_slug(dir: &str, key: &str, used: &mut HashSet<(String, String)>) -> String {
686 let base = {
687 let s = slug_like(key);
688 if s.is_empty() { "fact".to_string() } else { s }
689 };
690 if used.insert((dir.to_string(), base.clone())) {
691 return base;
692 }
693 let suffix = &blake3::hash(key.as_bytes()).to_hex()[..8];
694 let cand = format!("{base}-{suffix}");
695 used.insert((dir.to_string(), cand.clone()));
696 cand
697}
698
699fn relative_link(from_dir: &str, to: &ConceptLoc) -> String {
700 if from_dir == to.dir {
701 format!("{}.md", to.file)
702 } else {
703 format!("../{}/{}.md", to.dir, to.file)
704 }
705}
706
707fn concept_files(dir: &Path) -> Vec<std::path::PathBuf> {
710 let mut files: Vec<std::path::PathBuf> = WalkDir::new(dir)
711 .sort_by_file_name()
712 .into_iter()
713 .filter_map(Result::ok)
714 .filter(|e| e.file_type().is_file())
715 .map(walkdir::DirEntry::into_path)
716 .filter(|p| p.extension().is_some_and(|e| e == "md"))
717 .filter(|p| {
718 let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
719 name != INDEX_FILE && name != LOG_FILE
720 })
721 .collect();
722 files.sort();
723 files
724}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729 use crate::core::knowledge_relations::KnowledgeRelationGraph;
730
731 fn fact(
732 category: &str,
733 key: &str,
734 value: &str,
735 archetype: KnowledgeArchetype,
736 ) -> KnowledgeFact {
737 let now = Utc::now();
738 KnowledgeFact {
739 anchors: Vec::new(),
740 review: None,
741 capture: Default::default(),
742 origin: crate::core::knowledge::FactOrigin::Local,
743 category: category.into(),
744 key: key.into(),
745 value: value.into(),
746 source_session: "s1".into(),
747 confidence: 0.9,
748 created_at: now,
749 last_confirmed: now,
750 retrieval_count: 0,
751 last_retrieved: None,
752 valid_from: Some(now),
753 valid_until: None,
754 supersedes: None,
755 confirmation_count: 1,
756 feedback_up: 0,
757 feedback_down: 0,
758 last_feedback: None,
759 privacy: FactPrivacy::default(),
760 sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
761 imported_from: None,
762 archetype,
763 fidelity: None,
764 revision_count: 1,
765 status: None,
766 validity: None,
767 }
768 }
769
770 fn sample_snapshot() -> KnowledgeSnapshot {
771 let facts = vec![
772 fact(
773 "architecture",
774 "auth",
775 "Auth uses JWT RS256 tokens verified against Redis sessions.",
776 KnowledgeArchetype::Architecture,
777 ),
778 fact(
779 "architecture",
780 "db",
781 "PostgreSQL 16 with pgvector for the primary datastore.",
782 KnowledgeArchetype::Architecture,
783 ),
784 ];
785 let mut graph = KnowledgeRelationGraph::new("hash");
786 graph.upsert_edge(
787 KnowledgeNodeRef::new("architecture", "auth"),
788 KnowledgeNodeRef::new("architecture", "db"),
789 KnowledgeEdgeKind::DependsOn,
790 "s1",
791 );
792 KnowledgeSnapshot {
793 project_root: "/tmp/proj".into(),
794 project_hash: "hash".into(),
795 facts,
796 patterns: Vec::new(),
797 insights: Vec::new(),
798 relations: graph.edges,
799 task_id: None,
800 policy_version: None,
801 knowledge_refs: Vec::new(),
802 snapshot_hash: None,
803 }
804 }
805
806 fn key_set(facts: &[KnowledgeFact]) -> HashSet<(String, String, String, String)> {
807 facts
808 .iter()
809 .map(|f| {
810 (
811 f.category.clone(),
812 f.key.clone(),
813 f.value.clone(),
814 f.archetype.as_type_str().to_string(),
815 )
816 })
817 .collect()
818 }
819
820 #[test]
821 fn round_trip_preserves_facts_and_relations() {
822 let snap = sample_snapshot();
823 let bundle = to_okf_bundle(&snap);
824 let dir = tempfile::tempdir().unwrap();
825 write_okf_bundle(dir.path(), &bundle).unwrap();
826
827 let imported = from_okf_dir(dir.path()).unwrap();
828 assert_eq!(
829 key_set(&imported.facts),
830 key_set(&snap.facts),
831 "current facts (category/key/value/archetype) survive the round-trip"
832 );
833
834 assert_eq!(imported.edges.len(), 1, "the depends_on relation survives");
835 let e = &imported.edges[0];
836 assert_eq!(e.from, KnowledgeNodeRef::new("architecture", "auth"));
837 assert_eq!(e.to, KnowledgeNodeRef::new("architecture", "db"));
838 assert_eq!(e.kind, KnowledgeEdgeKind::DependsOn);
839 }
840
841 #[test]
842 fn export_is_byte_deterministic() {
843 let snap = sample_snapshot();
844 assert_eq!(
845 to_okf_bundle(&snap),
846 to_okf_bundle(&snap),
847 "two exports of the same snapshot are byte-identical (#498)"
848 );
849 }
850
851 #[test]
852 fn okf_concepts_match_current_facts() {
853 let snap = sample_snapshot();
856 let bundle = to_okf_bundle(&snap);
857 let concept_count = bundle
858 .keys()
859 .filter(|k| *k != INDEX_FILE && *k != LOG_FILE && !k.starts_with(PATTERNS_DIR))
860 .count();
861 assert_eq!(concept_count, snap.current_facts().len());
862 }
863
864 #[test]
865 fn foreign_bundle_needs_only_type() {
866 let dir = tempfile::tempdir().unwrap();
867 std::fs::write(
868 dir.path().join("note.md"),
869 "---\ntype: architecture\n---\n\nWe run everything on Kubernetes.\n",
870 )
871 .unwrap();
872
873 let imp = from_okf_dir(dir.path()).unwrap();
874 assert_eq!(
875 imp.facts.len(),
876 1,
877 "a type-only concept imports as one fact"
878 );
879 let f = &imp.facts[0];
880 assert_eq!(f.archetype, KnowledgeArchetype::Architecture);
881 assert_eq!(f.value, "We run everything on Kubernetes.");
882 assert_eq!(
883 f.key, "concept",
884 "no title/leanctx_key falls back to a default"
885 );
886 }
887
888 #[test]
889 fn unknown_frontmatter_keys_survive_a_parse_emit_cycle() {
890 let src = "custom_producer_key: \"keep me\"\ntype: \"fact\"\n";
893 let map = parse_frontmatter_map(src);
894 let pairs: Vec<(String, Value)> = map.into_iter().collect();
895 let emitted = emit_frontmatter(&pairs);
896 let reparsed = parse_frontmatter_map(
897 emitted
898 .trim_start_matches("---\n")
899 .trim_end_matches("---\n"),
900 );
901 assert_eq!(
902 reparsed.get("custom_producer_key").and_then(Value::as_str),
903 Some("keep me")
904 );
905 }
906
907 #[test]
908 fn lint_reports_warnings_never_panics() {
909 let dir = tempfile::tempdir().unwrap();
910 std::fs::write(dir.path().join("bad.md"), "no frontmatter here\n").unwrap();
911 std::fs::write(dir.path().join("ok.md"), "---\ntype: fact\n---\n\nbody\n").unwrap();
912 let warnings = lint_okf_bundle(dir.path());
913 assert!(
914 warnings.iter().any(|w| w.contains("bad.md")),
915 "the malformed file is flagged: {warnings:?}"
916 );
917 assert_eq!(from_okf_dir(dir.path()).unwrap().facts.len(), 1);
919 }
920
921 #[test]
922 fn patterns_round_trip_with_examples() {
923 let mut snap = sample_snapshot();
924 snap.patterns.push(ProjectPattern {
925 pattern_type: "naming".into(),
926 description: "snake_case for functions".into(),
927 examples: vec!["get_user()".into()],
928 source_session: "s1".into(),
929 created_at: Utc::now(),
930 });
931 let bundle = to_okf_bundle(&snap);
932 let dir = tempfile::tempdir().unwrap();
933 write_okf_bundle(dir.path(), &bundle).unwrap();
934
935 let imp = from_okf_dir(dir.path()).unwrap();
936 assert_eq!(imp.patterns.len(), 1);
937 assert_eq!(imp.patterns[0].pattern_type, "naming");
938 assert_eq!(imp.patterns[0].examples, vec!["get_user()".to_string()]);
939 }
940}