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 crate::core::config::Config::load()
9 .memory_policy_effective()
10 .map_err(|e| {
11 let path = crate::core::config::Config::path().map_or_else(
12 || "~/.lean-ctx/config.toml".to_string(),
13 |p| p.display().to_string(),
14 );
15 format!("Error: invalid memory policy: {e}\nFix: edit {path}")
16 })
17}
18
19fn ensure_current_fact_exists(knowledge: &ProjectKnowledge, node: &KnowledgeNodeRef) -> bool {
20 knowledge
21 .facts
22 .iter()
23 .any(|f| f.is_current() && f.category == node.category && f.key == node.key)
24}
25
26fn parse_kind_or_default(value: Option<&str>) -> Result<KnowledgeEdgeKind, String> {
27 let kind_str = value.unwrap_or("related_to");
28 KnowledgeEdgeKind::parse(kind_str).ok_or_else(|| {
29 "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes"
30 .to_string()
31 })
32}
33
34fn parse_target_or_error(query: Option<&str>) -> Result<KnowledgeNodeRef, String> {
35 let Some(q) = query else {
36 return Err("Error: query is required and must be 'category/key'".to_string());
37 };
38 parse_node_ref(q).ok_or_else(|| "Error: query must be 'category/key'".to_string())
39}
40
41fn parse_direction(query: Option<&str>) -> &'static str {
42 match query.unwrap_or("all").trim().to_lowercase().as_str() {
43 "in" | "incoming" => "in",
44 "out" | "outgoing" => "out",
45 _ => "all",
46 }
47}
48
49fn derived_supersedes_edges(
50 knowledge: &ProjectKnowledge,
51 focus: &KnowledgeNodeRef,
52) -> Vec<KnowledgeEdge> {
53 let mut out = Vec::new();
54 let focus_id = focus.id();
55
56 for f in knowledge.facts.iter().filter(|f| f.is_current()) {
57 if f.category == focus.category && f.key == focus.key {
58 if let Some(s) = &f.supersedes {
59 if let Some(to) = parse_node_ref(s) {
60 out.push(KnowledgeEdge {
61 from: focus.clone(),
62 to,
63 kind: KnowledgeEdgeKind::Supersedes,
64 created_at: f.created_at,
65 last_seen: None,
66 count: 0,
67 source_session: f.source_session.clone(),
68 });
69 }
70 }
71 } else if f.supersedes.as_deref() == Some(&focus_id) {
72 out.push(KnowledgeEdge {
73 from: KnowledgeNodeRef::new(&f.category, &f.key),
74 to: focus.clone(),
75 kind: KnowledgeEdgeKind::Supersedes,
76 created_at: f.created_at,
77 last_seen: None,
78 count: 0,
79 source_session: f.source_session.clone(),
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 let policy = match load_policy_or_error() {
113 Ok(p) => p,
114 Err(e) => 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 mut graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
132 let created = graph.upsert_edge(from.clone(), to.clone(), kind, session_id);
133 let max_edges = policy.knowledge.max_facts.saturating_mul(8);
134 let capped = graph.enforce_cap(max_edges);
135
136 match graph.save() {
137 Ok(()) => {
138 let verb = if created { "added" } else { "reinforced" };
139 let mut out = format!(
140 "Relation {verb}: {} -({})-> {}",
141 from.id(),
142 kind.as_str(),
143 to.id()
144 );
145 if capped {
146 out.push_str(&format!(" (note: capped to {max_edges} edges)"));
147 }
148 out
149 }
150 Err(e) => format!(
151 "Relation recorded but save failed: {e} ({} -({})-> {})",
152 from.id(),
153 kind.as_str(),
154 to.id()
155 ),
156 }
157}
158
159pub fn handle_unrelate(
160 project_root: &str,
161 category: Option<&str>,
162 key: Option<&str>,
163 value: Option<&str>,
164 query: Option<&str>,
165) -> String {
166 let Some(cat) = category else {
167 return "Error: category is required for unrelate".to_string();
168 };
169 let Some(k) = key else {
170 return "Error: key is required for unrelate".to_string();
171 };
172
173 let from = KnowledgeNodeRef::new(cat, k);
174 let to = match parse_target_or_error(query) {
175 Ok(n) => n,
176 Err(e) => return e,
177 };
178 let kind = if let Some(v) = value {
179 match KnowledgeEdgeKind::parse(v) {
180 Some(k) => Some(k),
181 None => {
182 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
183 }
184 }
185 } else {
186 None
187 };
188
189 let knowledge = ProjectKnowledge::load_or_create(project_root);
190 let mut graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
191 let removed = graph.remove_edge(&from, &to, kind);
192
193 if removed == 0 {
194 return format!("No matching relation found: {} -> {}", from.id(), to.id());
195 }
196
197 match graph.save() {
198 Ok(()) => format!("Relation removed ({removed}): {} -> {}", from.id(), to.id()),
199 Err(e) => format!(
200 "Relation removed ({removed}) but save failed: {e} ({} -> {})",
201 from.id(),
202 to.id()
203 ),
204 }
205}
206
207pub fn handle_relations(
208 project_root: &str,
209 category: Option<&str>,
210 key: Option<&str>,
211 value: Option<&str>,
212 query: Option<&str>,
213) -> String {
214 let Some(cat) = category else {
215 return "Error: category is required for relations".to_string();
216 };
217 let Some(k) = key else {
218 return "Error: key is required for relations".to_string();
219 };
220
221 let focus = KnowledgeNodeRef::new(cat, k);
222 let dir = parse_direction(query);
223 let kind_filter = match value {
224 Some(v) => match KnowledgeEdgeKind::parse(v) {
225 Some(k) => Some(k),
226 None => {
227 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
228 }
229 },
230 None => None,
231 };
232
233 let knowledge = ProjectKnowledge::load_or_create(project_root);
234 let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
235
236 let mut edges: Vec<&KnowledgeEdge> = graph
237 .edges
238 .iter()
239 .filter(|e| match dir {
240 "in" => e.to == focus,
241 "out" => e.from == focus,
242 _ => e.from == focus || e.to == focus,
243 })
244 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
245 .collect();
246
247 edges.sort_by(|a, b| {
248 a.kind
249 .as_str()
250 .cmp(b.kind.as_str())
251 .then_with(|| a.from.category.cmp(&b.from.category))
252 .then_with(|| a.from.key.cmp(&b.from.key))
253 .then_with(|| a.to.category.cmp(&b.to.category))
254 .then_with(|| a.to.key.cmp(&b.to.key))
255 .then_with(|| b.count.cmp(&a.count))
256 .then_with(|| b.last_seen.cmp(&a.last_seen))
257 .then_with(|| b.created_at.cmp(&a.created_at))
258 });
259
260 let derived = derived_supersedes_edges(&knowledge, &focus);
261 let mut derived_filtered: Vec<KnowledgeEdge> = derived
262 .into_iter()
263 .filter(|e| match dir {
264 "in" => e.to == focus,
265 "out" => e.from == focus,
266 _ => e.from == focus || e.to == focus,
267 })
268 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
269 .collect();
270 derived_filtered.sort_by(|a, b| {
271 a.kind
272 .as_str()
273 .cmp(b.kind.as_str())
274 .then_with(|| a.from.category.cmp(&b.from.category))
275 .then_with(|| a.from.key.cmp(&b.from.key))
276 .then_with(|| a.to.category.cmp(&b.to.category))
277 .then_with(|| a.to.key.cmp(&b.to.key))
278 });
279
280 let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
281 for e in &edges {
282 let _ = seen.insert((e.from.id(), e.to.id(), e.kind));
283 }
284 let derived_filtered: Vec<_> = derived_filtered
285 .into_iter()
286 .filter(|e| seen.insert((e.from.id(), e.to.id(), e.kind)))
287 .collect();
288
289 if edges.is_empty() && derived_filtered.is_empty() {
290 return format!("No relations for {}.", focus.id());
291 }
292
293 let mut out = Vec::new();
294 out.push(format!("Relations for {} (dir={dir}):", focus.id()));
295 for e in edges {
296 let arrow = if e.from == focus { "->" } else { "<-" };
297 let other = if e.from == focus { &e.to } else { &e.from };
298 out.push(format!(
299 " {arrow} {} {} (count={}, last_seen={})",
300 e.kind.as_str(),
301 other.id(),
302 e.count.max(1),
303 e.last_seen
304 .map_or_else(|| "n/a".to_string(), |t| t.format("%Y-%m-%d").to_string(),)
305 ));
306 }
307 for e in derived_filtered {
308 let arrow = if e.from == focus { "->" } else { "<-" };
309 let other = if e.from == focus { &e.to } else { &e.from };
310 out.push(format!(
311 " {arrow} {} {} (derived)",
312 e.kind.as_str(),
313 other.id()
314 ));
315 }
316 out.join("\n")
317}
318
319pub fn handle_relations_diagram(
320 project_root: &str,
321 category: Option<&str>,
322 key: Option<&str>,
323 value: Option<&str>,
324 query: Option<&str>,
325) -> String {
326 let Some(cat) = category else {
327 return "Error: category is required for relations_diagram".to_string();
328 };
329 let Some(k) = key else {
330 return "Error: key is required for relations_diagram".to_string();
331 };
332
333 let focus = KnowledgeNodeRef::new(cat, k);
334 let dir = parse_direction(query);
335 let kind_filter = match value {
336 Some(v) => match KnowledgeEdgeKind::parse(v) {
337 Some(k) => Some(k),
338 None => {
339 return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
340 }
341 },
342 None => None,
343 };
344
345 let knowledge = ProjectKnowledge::load_or_create(project_root);
346 let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
347
348 let mut edges: Vec<KnowledgeEdge> = graph
349 .edges
350 .iter()
351 .filter(|e| match dir {
352 "in" => e.to == focus,
353 "out" => e.from == focus,
354 _ => e.from == focus || e.to == focus,
355 })
356 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
357 .cloned()
358 .collect();
359
360 let derived = derived_supersedes_edges(&knowledge, &focus);
361 let derived_filtered = derived
362 .into_iter()
363 .filter(|e| match dir {
364 "in" => e.to == focus,
365 "out" => e.from == focus,
366 _ => e.from == focus || e.to == focus,
367 })
368 .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
369 .collect::<Vec<_>>();
370
371 let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
372 edges.retain(|e| seen.insert((e.from.id(), e.to.id(), e.kind)));
373 for e in derived_filtered {
374 if seen.insert((e.from.id(), e.to.id(), e.kind)) {
375 edges.push(e);
376 }
377 }
378
379 edges.sort_by(|a, b| {
380 a.kind
381 .as_str()
382 .cmp(b.kind.as_str())
383 .then_with(|| a.from.category.cmp(&b.from.category))
384 .then_with(|| a.from.key.cmp(&b.from.key))
385 .then_with(|| a.to.category.cmp(&b.to.category))
386 .then_with(|| a.to.key.cmp(&b.to.key))
387 });
388
389 format_mermaid(&edges)
390}