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