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