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