1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
6pub struct KnowledgeNodeRef {
7 pub category: String,
8 pub key: String,
9}
10
11impl KnowledgeNodeRef {
12 pub fn new(category: &str, key: &str) -> Self {
13 Self {
14 category: category.trim().to_string(),
15 key: key.trim().to_string(),
16 }
17 }
18
19 pub fn id(&self) -> String {
20 format!("{}/{}", self.category, self.key)
21 }
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
25#[serde(rename_all = "snake_case")]
26pub enum KnowledgeEdgeKind {
27 DependsOn,
28 RelatedTo,
29 Supports,
30 Contradicts,
31 Supersedes,
32}
33
34impl KnowledgeEdgeKind {
35 pub fn parse(input: &str) -> Option<Self> {
36 match input.trim().to_lowercase().as_str() {
37 "depends_on" | "depends" => Some(Self::DependsOn),
38 "related_to" | "related" => Some(Self::RelatedTo),
39 "supports" | "support" => Some(Self::Supports),
40 "contradicts" | "contradict" => Some(Self::Contradicts),
41 "supersedes" | "supersede" => Some(Self::Supersedes),
42 _ => None,
43 }
44 }
45
46 pub fn as_str(&self) -> &'static str {
47 match self {
48 KnowledgeEdgeKind::DependsOn => "depends_on",
49 KnowledgeEdgeKind::RelatedTo => "related_to",
50 KnowledgeEdgeKind::Supports => "supports",
51 KnowledgeEdgeKind::Contradicts => "contradicts",
52 KnowledgeEdgeKind::Supersedes => "supersedes",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct KnowledgeEdge {
59 pub from: KnowledgeNodeRef,
60 pub to: KnowledgeNodeRef,
61 pub kind: KnowledgeEdgeKind,
62 pub created_at: DateTime<Utc>,
63 #[serde(default)]
64 pub last_seen: Option<DateTime<Utc>>,
65 #[serde(default)]
66 pub count: u32,
67 pub source_session: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71#[serde(default)]
72pub struct KnowledgeRelationGraph {
73 pub project_hash: String,
74 pub edges: Vec<KnowledgeEdge>,
75 pub updated_at: DateTime<Utc>,
76}
77
78impl Default for KnowledgeRelationGraph {
79 fn default() -> Self {
80 Self {
81 project_hash: String::new(),
82 edges: Vec::new(),
83 updated_at: Utc::now(),
84 }
85 }
86}
87
88impl KnowledgeRelationGraph {
89 pub fn new(project_hash: &str) -> Self {
90 Self {
91 project_hash: project_hash.to_string(),
92 edges: Vec::new(),
93 updated_at: Utc::now(),
94 }
95 }
96
97 pub fn path(project_hash: &str) -> Result<PathBuf, String> {
98 let dir = crate::core::data_dir::lean_ctx_data_dir()?
99 .join("knowledge")
100 .join(project_hash);
101 Ok(dir.join("relations.json"))
102 }
103
104 pub fn load(project_hash: &str) -> Option<Self> {
105 let path = Self::path(project_hash).ok()?;
106 let content = std::fs::read_to_string(&path).ok()?;
107 let mut g = serde_json::from_str::<Self>(&content).ok()?;
108 if g.project_hash.trim().is_empty() {
109 g.project_hash = project_hash.to_string();
110 }
111 Some(g)
112 }
113
114 pub fn load_or_create(project_hash: &str) -> Self {
115 Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
116 }
117
118 pub fn save(&mut self) -> Result<(), String> {
119 let path = Self::path(&self.project_hash)?;
120 if let Some(dir) = path.parent() {
121 std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
122 }
123
124 self.updated_at = Utc::now();
125 self.edges.sort_by(|a, b| {
126 a.from
127 .category
128 .cmp(&b.from.category)
129 .then_with(|| a.from.key.cmp(&b.from.key))
130 .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
131 .then_with(|| a.to.category.cmp(&b.to.category))
132 .then_with(|| a.to.key.cmp(&b.to.key))
133 .then_with(|| b.count.cmp(&a.count))
134 .then_with(|| b.last_seen.cmp(&a.last_seen))
135 .then_with(|| b.created_at.cmp(&a.created_at))
136 });
137
138 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
139 std::fs::write(&path, json).map_err(|e| e.to_string())
140 }
141
142 pub fn upsert_edge(
143 &mut self,
144 from: KnowledgeNodeRef,
145 to: KnowledgeNodeRef,
146 kind: KnowledgeEdgeKind,
147 session_id: &str,
148 ) -> bool {
149 let now = Utc::now();
150 if let Some(e) = self
151 .edges
152 .iter_mut()
153 .find(|e| e.from == from && e.to == to && e.kind == kind)
154 {
155 e.count = e.count.saturating_add(1).max(1);
156 e.last_seen = Some(now);
157 e.source_session = session_id.to_string();
158 self.updated_at = now;
159 return false;
160 }
161
162 self.edges.push(KnowledgeEdge {
163 from,
164 to,
165 kind,
166 created_at: now,
167 last_seen: Some(now),
168 count: 1,
169 source_session: session_id.to_string(),
170 });
171 self.updated_at = now;
172 true
173 }
174
175 pub fn remove_edge(
176 &mut self,
177 from: &KnowledgeNodeRef,
178 to: &KnowledgeNodeRef,
179 kind: Option<KnowledgeEdgeKind>,
180 ) -> usize {
181 let before = self.edges.len();
182 self.edges.retain(|e| {
183 if &e.from != from || &e.to != to {
184 return true;
185 }
186 if let Some(k) = kind {
187 e.kind != k
188 } else {
189 false
190 }
191 });
192 before.saturating_sub(self.edges.len())
193 }
194
195 pub fn enforce_cap(&mut self, max_edges: usize) -> bool {
196 if max_edges == 0 || self.edges.len() <= max_edges {
197 return false;
198 }
199
200 self.edges.sort_by(|a, b| {
201 b.count
202 .cmp(&a.count)
203 .then_with(|| b.last_seen.cmp(&a.last_seen))
204 .then_with(|| b.created_at.cmp(&a.created_at))
205 .then_with(|| a.from.category.cmp(&b.from.category))
206 .then_with(|| a.from.key.cmp(&b.from.key))
207 .then_with(|| a.kind.as_str().cmp(b.kind.as_str()))
208 .then_with(|| a.to.category.cmp(&b.to.category))
209 .then_with(|| a.to.key.cmp(&b.to.key))
210 });
211
212 self.edges.truncate(max_edges);
213 true
214 }
215}
216
217pub fn parse_node_ref(input: &str) -> Option<KnowledgeNodeRef> {
218 let s = input.trim();
219 if s.is_empty() {
220 return None;
221 }
222
223 if let Some((cat, key)) = s.split_once('/') {
224 let cat = cat.trim();
225 let key = key.trim();
226 if !cat.is_empty() && !key.is_empty() {
227 return Some(KnowledgeNodeRef::new(cat, key));
228 }
229 }
230 if let Some((cat, key)) = s.split_once(':') {
231 let cat = cat.trim();
232 let key = key.trim();
233 if !cat.is_empty() && !key.is_empty() {
234 return Some(KnowledgeNodeRef::new(cat, key));
235 }
236 }
237
238 None
239}
240
241pub fn format_mermaid(edges: &[KnowledgeEdge]) -> String {
242 if edges.is_empty() {
243 return "graph TD\n %% no relations".to_string();
244 }
245
246 fn id_for(n: &KnowledgeNodeRef) -> String {
247 let mut out = String::from("K_");
248 for ch in n.id().chars() {
249 if ch.is_ascii_alphanumeric() {
250 out.push(ch);
251 } else {
252 out.push('_');
253 }
254 }
255 out
256 }
257
258 let mut lines = Vec::new();
259 lines.push("graph TD".to_string());
260 for e in edges {
261 let from = id_for(&e.from);
262 let to = id_for(&e.to);
263 let from_label = e.from.id();
264 let to_label = e.to.id();
265 lines.push(format!(
266 " {from}[\"{from_label}\"] -->|{}| {to}[\"{to_label}\"]",
267 e.kind.as_str()
268 ));
269 }
270 lines.join("\n")
271}