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