1use lean_ctx::core::knowledge::ProjectKnowledge;
3use lean_ctx::core::knowledge_relations::{
4 KnowledgeEdge, KnowledgeEdgeKind, KnowledgeNodeRef, KnowledgeRelationGraph, format_mermaid,
5 parse_node_ref,
6};
7
8fn load_policy_or_error() -> Result<lean_ctx::core::memory_policy::MemoryPolicy, String> {
9 super::knowledge_shared::load_policy_or_error()
10}
11
12fn ensure_current_fact_exists(knowledge: &ProjectKnowledge, node: &KnowledgeNodeRef) -> bool {
13 knowledge
14 .facts
15 .iter()
16 .any(|f| f.is_current() && f.category == node.category && f.key == node.key)
17}
18
19fn parse_kind_or_default(value: Option<&str>) -> Result<KnowledgeEdgeKind, String> {
20 let kind_str = value.unwrap_or("related_to");
21 KnowledgeEdgeKind::parse(kind_str).ok_or_else(|| {
22 "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes"
23 .to_string()
24 })
25}
26
27fn parse_target_or_error(query: Option<&str>) -> Result<KnowledgeNodeRef, String> {
28 let Some(q) = query else {
29 return Err("Error: query is required and must be 'category/key'".to_string());
30 };
31 parse_node_ref(q).ok_or_else(|| "Error: query must be 'category/key'".to_string())
32}
33
34fn parse_direction(query: Option<&str>) -> &'static str {
35 match query.unwrap_or("all").trim().to_lowercase().as_str() {
36 "in" | "incoming" => "in",
37 "out" | "outgoing" => "out",
38 _ => "all",
39 }
40}
41
42fn derived_supersedes_edges(
43 knowledge: &ProjectKnowledge,
44 focus: &KnowledgeNodeRef,
45) -> Vec<KnowledgeEdge> {
46 let mut out = Vec::new();
47 let focus_id = focus.id();
48
49 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
50 if f.category == focus.category && f.key == focus.key {
51 if let Some(s) = &f.supersedes
52 && let Some(to) = parse_node_ref(s)
53 {
54 if to == *focus {
55 continue;
56 }
57 out.push(KnowledgeEdge {
58 from: focus.clone(),
59 to,
60 kind: KnowledgeEdgeKind::Supersedes,
61 created_at: f.created_at,
62 last_seen: None,
63 count: 0,
64 source_session: f.source_session.clone(),
65 strength: 0.5,
66 decay_rate: 0.02,
67 });
68 }
69 } else if f.supersedes.as_deref() == Some(&focus_id) {
70 out.push(KnowledgeEdge {
71 from: KnowledgeNodeRef::new(&f.category, &f.key),
72 to: focus.clone(),
73 kind: KnowledgeEdgeKind::Supersedes,
74 created_at: f.created_at,
75 last_seen: None,
76 count: 0,
77 source_session: f.source_session.clone(),
78 strength: 0.5,
79 decay_rate: 0.02,
80 });
81 }
82 }
83
84 out
85}
86
87pub fn handle_relate(
88 project_root: &str,
89 category: Option<&str>,
90 key: Option<&str>,
91 value: Option<&str>,
92 query: Option<&str>,
93 session_id: &str,
94) -> String {
95 let Some(cat) = category else {
96 return "Error: category is required for relate".to_string();
97 };
98 let Some(k) = key else {
99 return "Error: key is required for relate".to_string();
100 };
101
102 let from = KnowledgeNodeRef::new(cat, k);
103 let to = match parse_target_or_error(query) {
104 Ok(n) => n,
105 Err(e) => return e,
106 };
107 let kind = match parse_kind_or_default(value) {
108 Ok(k) => k,
109 Err(e) => return e,
110 };
111
112 if let Err(e) = load_policy_or_error() {
114 return e;
115 }
116
117 let knowledge = ProjectKnowledge::load_or_create(project_root);
118 if !ensure_current_fact_exists(&knowledge, &from) {
119 return format!(
120 "Error: no current fact exists for [{}] {}. Use ctx_knowledge remember first.",
121 from.category, from.key
122 );
123 }
124 if !ensure_current_fact_exists(&knowledge, &to) {
125 return format!(
126 "Error: no current fact exists for [{}] {}. Use ctx_knowledge remember first.",
127 to.category, to.key
128 );
129 }
130
131 let max_edges = lean_ctx::core::knowledge_relations::edge_bound(
132 knowledge.facts.iter().filter(|f| f.is_current()).count(),
133 );
134 let saved =
135 KnowledgeRelationGraph::mutate_locked(project_root, &knowledge.project_hash, |graph| {
136 let created = graph.upsert_edge(from.clone(), to.clone(), kind, session_id);
137 Some((created, graph.enforce_cap(max_edges)))
138 });
139
140 match saved.map(Option::unwrap_or_default) {
141 Ok((created, capped)) => {
142 let verb = if created { "added" } else { "reinforced" };
143 let mut out = format!(
144 "Relation {verb}: {} -({})-> {}",
145 from.id(),
146 kind.as_str(),
147 to.id()
148 );
149 if capped {
150 out.push_str(&format!(" (note: capped to {max_edges} edges)"));
151 }
152 out
153 }
154 Err(e) => format!(
155 "Relation not saved: {e} ({} -({})-> {}); retry once the knowledge store is free",
156 from.id(),
157 kind.as_str(),
158 to.id()
159 ),
160 }
161}
162
163pub fn handle_unrelate(
164 project_root: &str,
165 category: Option<&str>,
166 key: Option<&str>,
167 value: Option<&str>,
168 query: Option<&str>,
169) -> String {
170 let Some(cat) = category else {
171 return "Error: category is required for unrelate".to_string();
172 };
173 let Some(k) = key else {
174 return "Error: key is required for unrelate".to_string();
175 };
176
177 let from = KnowledgeNodeRef::new(cat, k);
178 let to = match parse_target_or_error(query) {
179 Ok(n) => n,
180 Err(e) => return e,
181 };
182 let kind = if let Some(v) = value {
183 match KnowledgeEdgeKind::parse(v) {
184 Some(k) => Some(k),
185 None => {
186 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
187 }
188 }
189 } else {
190 None
191 };
192
193 let knowledge = ProjectKnowledge::load_or_create(project_root);
194 let removed =
195 KnowledgeRelationGraph::mutate_locked(project_root, &knowledge.project_hash, |graph| {
196 let removed = graph.remove_edge(&from, &to, kind);
197 (removed > 0).then_some(removed)
198 });
199
200 match removed {
201 Ok(Some(removed)) => format!("Relation removed ({removed}): {} -> {}", from.id(), to.id()),
202 Ok(None) => format!("No matching relation found: {} -> {}", from.id(), to.id()),
203 Err(e) => format!(
204 "Relation not removed: {e} ({} -> {}); retry once the knowledge store is free",
205 from.id(),
206 to.id()
207 ),
208 }
209}
210
211pub fn handle_relations(
212 project_root: &str,
213 category: Option<&str>,
214 key: Option<&str>,
215 value: Option<&str>,
216 query: Option<&str>,
217) -> String {
218 let Some(cat) = category else {
219 return "Error: category is required for relations".to_string();
220 };
221 let Some(k) = key else {
222 return "Error: key is required for relations".to_string();
223 };
224
225 let focus = KnowledgeNodeRef::new(cat, k);
226 let dir = parse_direction(query);
227 let kind_filter = match value {
228 Some(v) => match KnowledgeEdgeKind::parse(v) {
229 Some(k) => Some(k),
230 None => {
231 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
232 }
233 },
234 None => None,
235 };
236
237 let policy = match load_policy_or_error() {
238 Ok(p) => p,
239 Err(e) => return e,
240 };
241 let limit = policy.knowledge.relations_limit;
242
243 let knowledge = ProjectKnowledge::load_or_create(project_root);
244 let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
245
246 let mut edges: Vec<&KnowledgeEdge> = graph
247 .incident_edges(&focus)
248 .into_iter()
249 .filter(|e| match dir {
250 "in" => e.to == focus,
251 "out" => e.from == focus,
252 _ => e.from == focus || e.to == focus,
253 })
254 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
255 .collect();
256
257 edges.sort_by(|a, b| {
258 a.kind
259 .as_str()
260 .cmp(b.kind.as_str())
261 .then_with(|| a.from.category.cmp(&b.from.category))
262 .then_with(|| a.from.key.cmp(&b.from.key))
263 .then_with(|| a.to.category.cmp(&b.to.category))
264 .then_with(|| a.to.key.cmp(&b.to.key))
265 .then_with(|| b.count.cmp(&a.count))
266 .then_with(|| b.last_seen.cmp(&a.last_seen))
267 .then_with(|| b.created_at.cmp(&a.created_at))
268 });
269
270 let derived = derived_supersedes_edges(&knowledge, &focus);
271 let mut derived_filtered: Vec<KnowledgeEdge> = derived
272 .into_iter()
273 .filter(|e| match dir {
274 "in" => e.to == focus,
275 "out" => e.from == focus,
276 _ => e.from == focus || e.to == focus,
277 })
278 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
279 .collect();
280 derived_filtered.sort_by(|a, b| {
281 a.kind
282 .as_str()
283 .cmp(b.kind.as_str())
284 .then_with(|| a.from.category.cmp(&b.from.category))
285 .then_with(|| a.from.key.cmp(&b.from.key))
286 .then_with(|| a.to.category.cmp(&b.to.category))
287 .then_with(|| a.to.key.cmp(&b.to.key))
288 });
289
290 let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
291 for e in &edges {
292 let _ = seen.insert((e.from.id(), e.to.id(), e.kind));
293 }
294 let derived_filtered: Vec<_> = derived_filtered
295 .into_iter()
296 .filter(|e| seen.insert((e.from.id(), e.to.id(), e.kind)))
297 .collect();
298
299 if edges.is_empty() && derived_filtered.is_empty() {
300 return format!("No relations for {}.", focus.id());
301 }
302
303 let mut out = Vec::new();
304 let total = edges.len() + derived_filtered.len();
305 let mut shown = 0usize;
306 let mut remaining = limit;
307
308 for e in edges.iter().take(remaining) {
309 let arrow = if e.from == focus { "->" } else { "<-" };
310 let other = if e.from == focus { &e.to } else { &e.from };
311 out.push(format!(
312 " {arrow} {} {} (count={}, last_seen={})",
313 e.kind.as_str(),
314 other.id(),
315 e.count.max(1),
316 e.last_seen
317 .map_or_else(|| "n/a".to_string(), |t| t.format("%Y-%m-%d").to_string(),)
318 ));
319 shown += 1;
320 remaining = remaining.saturating_sub(1);
321 }
322
323 for e in derived_filtered.into_iter().take(remaining) {
324 let arrow = if e.from == focus { "->" } else { "<-" };
325 let other = if e.from == focus { &e.to } else { &e.from };
326 out.push(format!(
327 " {arrow} {} {} (derived)",
328 e.kind.as_str(),
329 other.id()
330 ));
331 shown += 1;
332 remaining = remaining.saturating_sub(1);
333 }
334
335 out.insert(
336 0,
337 format!(
338 "Relations for {} (dir={dir}, showing {shown}/{total}):",
339 focus.id()
340 ),
341 );
342 if total > shown {
343 out.push(format!(" … +{} more", total - shown));
344 }
345 out.join("\n")
346}
347
348pub fn handle_relations_diagram(
349 project_root: &str,
350 category: Option<&str>,
351 key: Option<&str>,
352 value: Option<&str>,
353 query: Option<&str>,
354) -> String {
355 let Some(cat) = category else {
356 return "Error: category is required for relations_diagram".to_string();
357 };
358 let Some(k) = key else {
359 return "Error: key is required for relations_diagram".to_string();
360 };
361
362 let focus = KnowledgeNodeRef::new(cat, k);
363 let dir = parse_direction(query);
364 let kind_filter = match value {
365 Some(v) => match KnowledgeEdgeKind::parse(v) {
366 Some(k) => Some(k),
367 None => {
368 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
369 }
370 },
371 None => None,
372 };
373
374 let policy = match load_policy_or_error() {
375 Ok(p) => p,
376 Err(e) => return e,
377 };
378 let limit = policy.knowledge.relations_limit;
379
380 let knowledge = ProjectKnowledge::load_or_create(project_root);
381 let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
382
383 let mut edges: Vec<KnowledgeEdge> = graph
384 .incident_edges(&focus)
385 .into_iter()
386 .filter(|e| match dir {
387 "in" => e.to == focus,
388 "out" => e.from == focus,
389 _ => e.from == focus || e.to == focus,
390 })
391 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
392 .cloned()
393 .collect();
394
395 let derived = derived_supersedes_edges(&knowledge, &focus);
396 let derived_filtered = derived
397 .into_iter()
398 .filter(|e| match dir {
399 "in" => e.to == focus,
400 "out" => e.from == focus,
401 _ => e.from == focus || e.to == focus,
402 })
403 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
404 .collect::<Vec<_>>();
405
406 let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
407 edges.retain(|e| seen.insert((e.from.id(), e.to.id(), e.kind)));
408 for e in derived_filtered {
409 if seen.insert((e.from.id(), e.to.id(), e.kind)) {
410 edges.push(e);
411 }
412 }
413
414 edges.sort_by(|a, b| {
415 a.kind
416 .as_str()
417 .cmp(b.kind.as_str())
418 .then_with(|| a.from.category.cmp(&b.from.category))
419 .then_with(|| a.from.key.cmp(&b.from.key))
420 .then_with(|| a.to.category.cmp(&b.to.category))
421 .then_with(|| a.to.key.cmp(&b.to.key))
422 });
423
424 let truncated = edges.len() > limit;
425 if truncated {
426 edges.truncate(limit);
427 }
428
429 let mut out = format_mermaid(&edges);
430 if truncated {
431 out = format!("%% truncated to {limit} edges\n{out}");
432 }
433 out
434}