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                    });
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}