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> {
155 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
156 for (rel, contents) in bundle {
157 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
178fn 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
320pub 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
360pub 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 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
533fn 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
576fn 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
604fn 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
649fn 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
661fn 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
683fn 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
708fn 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 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 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 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 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 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 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}