Skip to main content

lean_ctx/tools/
ctx_knowledge_relations.rs

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}