Skip to main content

lean_ctx/core/knowledge/
persist.rs

1use chrono::Utc;
2use std::path::PathBuf;
3
4use super::ranking::hash_project_root;
5use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
6use crate::core::memory_policy::MemoryPolicy;
7
8fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
9    Ok(crate::core::data_dir::lean_ctx_data_dir()?
10        .join("knowledge")
11        .join(project_hash))
12}
13
14impl ProjectKnowledge {
15    pub fn save(&self) -> Result<(), String> {
16        let dir = knowledge_dir(&self.project_hash)?;
17        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
18
19        let path = dir.join("knowledge.json");
20        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
21        std::fs::write(&path, json).map_err(|e| e.to_string())
22    }
23
24    pub fn load(project_root: &str) -> Option<Self> {
25        let hash = hash_project_root(project_root);
26        let dir = knowledge_dir(&hash).ok()?;
27        let path = dir.join("knowledge.json");
28
29        if let Ok(content) = std::fs::read_to_string(&path) {
30            if let Ok(k) = serde_json::from_str::<Self>(&content) {
31                return Some(k);
32            }
33        }
34
35        let old_hash = crate::core::project_hash::hash_path_only(project_root);
36        if old_hash != hash {
37            crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
38            if let Ok(content) = std::fs::read_to_string(&path) {
39                if let Ok(mut k) = serde_json::from_str::<Self>(&content) {
40                    k.project_hash = hash;
41                    let _ = k.save();
42                    return Some(k);
43                }
44            }
45        }
46
47        None
48    }
49
50    pub fn load_or_create(project_root: &str) -> Self {
51        Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
52    }
53
54    /// Migrates legacy knowledge that was accidentally stored under an empty project_root ("")
55    /// into the given `target_root`. Keeps a timestamped backup of the legacy file.
56    pub fn migrate_legacy_empty_root(
57        target_root: &str,
58        policy: &MemoryPolicy,
59    ) -> Result<bool, String> {
60        if target_root.trim().is_empty() {
61            return Ok(false);
62        }
63
64        let Some(legacy) = Self::load("") else {
65            return Ok(false);
66        };
67
68        if !legacy.project_root.trim().is_empty() {
69            return Ok(false);
70        }
71        if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
72            return Ok(false);
73        }
74
75        let mut target = Self::load_or_create(target_root);
76
77        fn fact_key(f: &KnowledgeFact) -> String {
78            format!(
79                "{}|{}|{}|{}|{}",
80                f.category, f.key, f.value, f.source_session, f.created_at
81            )
82        }
83        fn pattern_key(p: &ProjectPattern) -> String {
84            format!(
85                "{}|{}|{}|{}",
86                p.pattern_type, p.description, p.source_session, p.created_at
87            )
88        }
89        fn history_key(h: &ConsolidatedInsight) -> String {
90            format!(
91                "{}|{}|{}",
92                h.summary,
93                h.from_sessions.join(","),
94                h.timestamp
95            )
96        }
97
98        let mut seen_facts: std::collections::HashSet<String> =
99            target.facts.iter().map(fact_key).collect();
100        for f in legacy.facts {
101            if seen_facts.insert(fact_key(&f)) {
102                target.facts.push(f);
103            }
104        }
105
106        let mut seen_patterns: std::collections::HashSet<String> =
107            target.patterns.iter().map(pattern_key).collect();
108        for p in legacy.patterns {
109            if seen_patterns.insert(pattern_key(&p)) {
110                target.patterns.push(p);
111            }
112        }
113
114        let mut seen_history: std::collections::HashSet<String> =
115            target.history.iter().map(history_key).collect();
116        for h in legacy.history {
117            if seen_history.insert(history_key(&h)) {
118                target.history.push(h);
119            }
120        }
121
122        target.facts.sort_by(|a, b| {
123            b.created_at
124                .cmp(&a.created_at)
125                .then_with(|| b.confidence.total_cmp(&a.confidence))
126        });
127        if target.facts.len() > policy.knowledge.max_facts {
128            target.facts.truncate(policy.knowledge.max_facts);
129        }
130        target
131            .patterns
132            .sort_by_key(|x| std::cmp::Reverse(x.created_at));
133        if target.patterns.len() > policy.knowledge.max_patterns {
134            target.patterns.truncate(policy.knowledge.max_patterns);
135        }
136        target
137            .history
138            .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
139        if target.history.len() > policy.knowledge.max_history {
140            target.history.truncate(policy.knowledge.max_history);
141        }
142
143        target.updated_at = Utc::now();
144        target.save()?;
145
146        let legacy_hash = crate::core::project_hash::hash_path_only("");
147        let legacy_dir = knowledge_dir(&legacy_hash)?;
148        let legacy_path = legacy_dir.join("knowledge.json");
149        if legacy_path.exists() {
150            let ts = Utc::now().format("%Y%m%d-%H%M%S");
151            let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
152            std::fs::rename(&legacy_path, &backup).map_err(|e| e.to_string())?;
153        }
154
155        Ok(true)
156    }
157}