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