Skip to main content

kg/
output.rs

1use std::cmp::Reverse;
2use std::collections::{HashSet, VecDeque};
3
4use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
5use nucleo_matcher::{Config, Matcher, Utf32Str};
6
7use crate::graph::{Edge, GraphFile, Node};
8use crate::index::Bm25Index;
9
10const BM25_K1: f64 = 1.5;
11const BM25_B: f64 = 0.75;
12const DEFAULT_TARGET_CHARS: usize = 1400;
13const MIN_TARGET_CHARS: usize = 300;
14const MAX_TARGET_CHARS: usize = 12_000;
15
16#[derive(Debug, Clone, Copy)]
17pub enum FindMode {
18    Fuzzy,
19    Bm25,
20}
21
22pub fn render_find(
23    graph: &GraphFile,
24    queries: &[String],
25    limit: usize,
26    include_features: bool,
27    mode: FindMode,
28    full: bool,
29) -> String {
30    render_find_with_index(graph, queries, limit, include_features, mode, full, None)
31}
32
33pub fn render_find_with_index(
34    graph: &GraphFile,
35    queries: &[String],
36    limit: usize,
37    include_features: bool,
38    mode: FindMode,
39    full: bool,
40    index: Option<&Bm25Index>,
41) -> String {
42    let mut sections = Vec::new();
43    for query in queries {
44        let matches = find_all_matches_with_index(graph, query, include_features, mode, index);
45        let total = matches.len();
46        let visible: Vec<_> = matches.into_iter().take(limit).collect();
47        let shown = visible.len();
48        let mut lines = vec![render_result_header(query, shown, total)];
49        for node in visible {
50            lines.push(render_node_block(graph, node, full));
51        }
52        push_limit_omission_line(&mut lines, shown, total);
53        sections.push(lines.join("\n"));
54    }
55    format!("{}\n", sections.join("\n\n"))
56}
57
58pub fn find_nodes(
59    graph: &GraphFile,
60    query: &str,
61    limit: usize,
62    include_features: bool,
63    mode: FindMode,
64) -> Vec<Node> {
65    find_matches_with_index(graph, query, limit, include_features, mode, None)
66        .into_iter()
67        .cloned()
68        .collect()
69}
70
71pub fn find_nodes_with_index(
72    graph: &GraphFile,
73    query: &str,
74    limit: usize,
75    include_features: bool,
76    mode: FindMode,
77    index: Option<&Bm25Index>,
78) -> Vec<Node> {
79    find_matches_with_index(graph, query, limit, include_features, mode, index)
80        .into_iter()
81        .cloned()
82        .collect()
83}
84
85pub fn find_nodes_and_total_with_index(
86    graph: &GraphFile,
87    query: &str,
88    limit: usize,
89    include_features: bool,
90    mode: FindMode,
91    index: Option<&Bm25Index>,
92) -> (usize, Vec<Node>) {
93    let matches = find_all_matches_with_index(graph, query, include_features, mode, index);
94    let total = matches.len();
95    let nodes = matches.into_iter().take(limit).cloned().collect();
96    (total, nodes)
97}
98
99pub fn count_find_results(
100    graph: &GraphFile,
101    queries: &[String],
102    limit: usize,
103    include_features: bool,
104    mode: FindMode,
105) -> usize {
106    count_find_results_with_index(graph, queries, limit, include_features, mode, None)
107}
108
109pub fn count_find_results_with_index(
110    graph: &GraphFile,
111    queries: &[String],
112    _limit: usize,
113    include_features: bool,
114    mode: FindMode,
115    index: Option<&Bm25Index>,
116) -> usize {
117    let mut total = 0;
118    for query in queries {
119        total += find_all_matches_with_index(graph, query, include_features, mode, index).len();
120    }
121    total
122}
123
124pub fn render_node(graph: &GraphFile, node: &Node, full: bool) -> String {
125    format!("{}\n", render_node_block(graph, node, full))
126}
127
128pub fn render_node_adaptive(graph: &GraphFile, node: &Node, target_chars: Option<usize>) -> String {
129    let target = clamp_target_chars(target_chars);
130    let full = format!("{}\n", render_node_block(graph, node, true));
131    if fits_target_chars(&full, target) {
132        return full;
133    }
134    let mut candidates = Vec::new();
135    for (depth, detail, edge_cap) in [
136        (0usize, DetailLevel::Rich, 8usize),
137        (1usize, DetailLevel::Rich, 8usize),
138        (2usize, DetailLevel::Rich, 6usize),
139        (2usize, DetailLevel::Compact, 6usize),
140        (2usize, DetailLevel::Minimal, 2usize),
141    ] {
142        let rendered = render_single_node_candidate(graph, node, depth, detail, edge_cap);
143        candidates.push(Candidate {
144            rendered,
145            depth,
146            detail,
147            shown_nodes: 1 + depth,
148        });
149    }
150    pick_best_candidate(candidates, target)
151}
152
153pub fn render_find_adaptive_with_index(
154    graph: &GraphFile,
155    queries: &[String],
156    limit: usize,
157    include_features: bool,
158    mode: FindMode,
159    target_chars: Option<usize>,
160    index: Option<&Bm25Index>,
161) -> String {
162    let target = clamp_target_chars(target_chars);
163    let mut sections = Vec::new();
164    for query in queries {
165        let matches = find_all_matches_with_index(graph, query, include_features, mode, index);
166        let total = matches.len();
167        let visible: Vec<_> = matches.into_iter().take(limit).collect();
168        let section = if visible.len() == 1 {
169            render_single_result_section(graph, query, visible[0], total, target)
170        } else {
171            render_multi_result_section(graph, query, &visible, total, target)
172        };
173        sections.push(section);
174    }
175    format!("{}\n", sections.join("\n\n"))
176}
177
178#[derive(Clone, Copy)]
179enum DetailLevel {
180    Rich,
181    Compact,
182    Minimal,
183}
184
185struct Candidate {
186    rendered: String,
187    depth: usize,
188    detail: DetailLevel,
189    shown_nodes: usize,
190}
191
192impl DetailLevel {
193    fn utility_bonus(self) -> usize {
194        match self {
195            DetailLevel::Rich => 20,
196            DetailLevel::Compact => 10,
197            DetailLevel::Minimal => 0,
198        }
199    }
200}
201
202fn clamp_target_chars(target_chars: Option<usize>) -> usize {
203    target_chars
204        .unwrap_or(DEFAULT_TARGET_CHARS)
205        .clamp(MIN_TARGET_CHARS, MAX_TARGET_CHARS)
206}
207
208fn render_single_result_section(
209    graph: &GraphFile,
210    query: &str,
211    node: &Node,
212    total_available: usize,
213    target: usize,
214) -> String {
215    let header = render_result_header(query, 1, total_available);
216    let full = render_single_result_candidate(
217        graph,
218        &header,
219        node,
220        total_available,
221        0,
222        DetailLevel::Rich,
223        8,
224        true,
225    );
226    if fits_target_chars(&full, target) {
227        return full.trim_end().to_owned();
228    }
229    let mut candidates = Vec::new();
230    for (depth, detail, edge_cap) in [
231        (0usize, DetailLevel::Rich, 8usize),
232        (1usize, DetailLevel::Rich, 8usize),
233        (2usize, DetailLevel::Rich, 6usize),
234        (2usize, DetailLevel::Compact, 6usize),
235        (2usize, DetailLevel::Minimal, 2usize),
236    ] {
237        candidates.push(Candidate {
238            rendered: render_single_result_candidate(
239                graph,
240                &header,
241                node,
242                total_available,
243                depth,
244                detail,
245                edge_cap,
246                false,
247            ),
248            depth,
249            detail,
250            shown_nodes: 1 + depth,
251        });
252    }
253    pick_best_candidate(candidates, target)
254        .trim_end()
255        .to_owned()
256}
257
258fn render_multi_result_section(
259    graph: &GraphFile,
260    query: &str,
261    nodes: &[&Node],
262    total_available: usize,
263    target: usize,
264) -> String {
265    let visible_total = nodes.len();
266    let full = render_full_result_section(graph, query, nodes, total_available);
267    if fits_target_chars(&full, target) {
268        return full;
269    }
270    let mut candidates = Vec::new();
271    let full_cap = visible_total;
272    let mid_cap = full_cap.min(5);
273    let low_cap = full_cap.min(3);
274
275    for (detail, edge_cap, result_cap, depth) in [
276        (DetailLevel::Rich, 4usize, full_cap.min(4), 0usize),
277        (DetailLevel::Compact, 3usize, full_cap, 0usize),
278        (DetailLevel::Rich, 2usize, mid_cap, 1usize),
279        (DetailLevel::Compact, 1usize, full_cap, 0usize),
280        (DetailLevel::Minimal, 1usize, mid_cap, 0usize),
281        (DetailLevel::Minimal, 0usize, low_cap, 0usize),
282        (DetailLevel::Minimal, 0usize, low_cap.min(2), 1usize),
283    ] {
284        let shown = result_cap.min(nodes.len());
285        let mut lines = vec![render_result_header(query, shown, total_available)];
286        for node in nodes.iter().take(shown) {
287            lines.extend(render_single_node_candidate_lines(
288                graph, node, 0, detail, edge_cap,
289            ));
290            if depth > 0 {
291                lines.extend(render_neighbor_layers(graph, node, depth, detail));
292            }
293        }
294        if visible_total > shown {
295            lines.push(format!("... +{} more nodes omitted", visible_total - shown));
296        }
297        push_limit_omission_line(&mut lines, visible_total, total_available);
298        candidates.push(Candidate {
299            rendered: format!("{}\n", lines.join("\n")),
300            depth,
301            detail,
302            shown_nodes: shown,
303        });
304    }
305
306    pick_best_candidate(candidates, target)
307        .trim_end()
308        .to_owned()
309}
310
311fn pick_best_candidate(candidates: Vec<Candidate>, target: usize) -> String {
312    let lower = (target as f64 * 0.7) as usize;
313    let mut best: Option<(usize, usize, usize, usize, String)> = None;
314
315    for candidate in candidates {
316        let chars = candidate.rendered.chars().count();
317        let overshoot = chars.saturating_sub(target);
318        let undershoot = lower.saturating_sub(chars);
319        let penalty = overshoot.saturating_mul(10).saturating_add(undershoot);
320        let utility = candidate
321            .depth
322            .saturating_mul(100)
323            .saturating_add(candidate.shown_nodes.saturating_mul(5))
324            .saturating_add(candidate.detail.utility_bonus());
325
326        let entry = (
327            penalty,
328            overshoot,
329            usize::MAX - utility,
330            usize::MAX - chars,
331            candidate.rendered,
332        );
333        if best.as_ref().is_none_or(|current| {
334            entry.0 < current.0
335                || (entry.0 == current.0 && entry.1 < current.1)
336                || (entry.0 == current.0 && entry.1 == current.1 && entry.2 < current.2)
337                || (entry.0 == current.0
338                    && entry.1 == current.1
339                    && entry.2 == current.2
340                    && entry.3 < current.3)
341        }) {
342            best = Some(entry);
343        }
344    }
345
346    best.map(|item| item.4).unwrap_or_else(|| "\n".to_owned())
347}
348
349fn render_full_result_section(
350    graph: &GraphFile,
351    query: &str,
352    nodes: &[&Node],
353    total_available: usize,
354) -> String {
355    let mut lines = vec![render_result_header(query, nodes.len(), total_available)];
356    for node in nodes {
357        lines.push(render_node_block(graph, node, true));
358    }
359    push_limit_omission_line(&mut lines, nodes.len(), total_available);
360    lines.join("\n")
361}
362
363fn render_result_header(query: &str, shown: usize, total: usize) -> String {
364    let query = escape_cli_text(query);
365    if shown < total {
366        format!("? {query} ({shown}/{total})")
367    } else {
368        format!("? {query} ({total})")
369    }
370}
371
372fn push_limit_omission_line(lines: &mut Vec<String>, shown: usize, total: usize) {
373    let omitted = total.saturating_sub(shown);
374    if omitted > 0 {
375        lines.push(format!("... {omitted} more nodes omitted by limit"));
376    }
377}
378
379fn fits_target_chars(rendered: &str, target: usize) -> bool {
380    rendered.chars().count() <= target
381}
382
383fn render_single_node_candidate(
384    graph: &GraphFile,
385    node: &Node,
386    depth: usize,
387    detail: DetailLevel,
388    edge_cap: usize,
389) -> String {
390    let lines = render_single_node_candidate_lines(graph, node, depth, detail, edge_cap);
391    format!("{}\n", lines.join("\n"))
392}
393
394fn render_single_result_candidate(
395    graph: &GraphFile,
396    header: &str,
397    node: &Node,
398    total_available: usize,
399    depth: usize,
400    detail: DetailLevel,
401    edge_cap: usize,
402    full: bool,
403) -> String {
404    let mut lines = vec![header.to_owned()];
405    if full {
406        lines.push(render_node_block(graph, node, true));
407    } else {
408        lines.extend(render_single_node_candidate_lines(
409            graph, node, depth, detail, edge_cap,
410        ));
411    }
412    push_limit_omission_line(&mut lines, 1, total_available);
413    format!("{}\n", lines.join("\n"))
414}
415
416fn render_single_node_candidate_lines(
417    graph: &GraphFile,
418    node: &Node,
419    depth: usize,
420    detail: DetailLevel,
421    edge_cap: usize,
422) -> Vec<String> {
423    let mut lines = render_node_lines_with_edges(graph, node, detail, edge_cap);
424    if depth > 0 {
425        lines.extend(render_neighbor_layers(graph, node, depth, detail));
426    }
427    lines
428}
429
430fn render_neighbor_layers(
431    graph: &GraphFile,
432    root: &Node,
433    max_depth: usize,
434    detail: DetailLevel,
435) -> Vec<String> {
436    let mut out = Vec::new();
437    let mut seen: HashSet<String> = HashSet::from([root.id.clone()]);
438    let mut queue: VecDeque<(String, usize)> = VecDeque::from([(root.id.clone(), 0usize)]);
439    let mut layers: Vec<Vec<&Node>> = vec![Vec::new(); max_depth + 1];
440
441    while let Some((node_id, depth)) = queue.pop_front() {
442        if depth >= max_depth {
443            continue;
444        }
445        for incident in incident_edges(graph, &node_id) {
446            if seen.insert(incident.related.id.clone()) {
447                let next_depth = depth + 1;
448                if next_depth <= max_depth {
449                    layers[next_depth].push(incident.related);
450                    queue.push_back((incident.related.id.clone(), next_depth));
451                }
452            }
453        }
454    }
455
456    for depth in 1..=max_depth {
457        if layers[depth].is_empty() {
458            continue;
459        }
460        let cap = match detail {
461            DetailLevel::Rich => 6,
462            DetailLevel::Compact => 4,
463            DetailLevel::Minimal => 3,
464        };
465        let shown = layers[depth].len().min(cap);
466        out.push(format!(
467            "depth {depth}: {shown}/{} neighbors",
468            layers[depth].len()
469        ));
470        for node in layers[depth].iter().take(shown) {
471            out.extend(render_node_identity_lines(node, detail));
472        }
473        if layers[depth].len() > shown {
474            out.push(format!(
475                "... +{} more neighbors omitted",
476                layers[depth].len() - shown
477            ));
478        }
479    }
480
481    out
482}
483
484fn render_node_lines_with_edges(
485    graph: &GraphFile,
486    node: &Node,
487    detail: DetailLevel,
488    edge_cap: usize,
489) -> Vec<String> {
490    let mut lines = render_node_identity_lines(node, detail);
491    lines.extend(render_node_link_lines(graph, node, edge_cap));
492    lines
493}
494
495fn render_node_identity_lines(node: &Node, detail: DetailLevel) -> Vec<String> {
496    let mut lines = Vec::new();
497    match detail {
498        DetailLevel::Rich => {
499            lines.push(format!(
500                "# {} | {} [{}]",
501                node.id,
502                escape_cli_text(&node.name),
503                node.r#type
504            ));
505            if !node.properties.alias.is_empty() {
506                lines.push(format!(
507                    "aka: {}",
508                    node.properties
509                        .alias
510                        .iter()
511                        .map(|alias| escape_cli_text(alias))
512                        .collect::<Vec<_>>()
513                        .join(", ")
514                ));
515            }
516            push_description_line(&mut lines, &node.properties.description, None);
517            let shown_facts = node.properties.key_facts.len().min(3);
518            for fact in node.properties.key_facts.iter().take(shown_facts) {
519                lines.push(format!("- {}", escape_cli_text(fact)));
520            }
521            let omitted = node.properties.key_facts.len().saturating_sub(shown_facts);
522            if omitted > 0 {
523                lines.push(format!("... {omitted} more facts omitted"));
524            }
525        }
526        DetailLevel::Compact => {
527            lines.push(format!(
528                "# {} | {} [{}]",
529                node.id,
530                escape_cli_text(&node.name),
531                node.r#type
532            ));
533            push_description_line(&mut lines, &node.properties.description, Some(140));
534            if let Some(fact) = node.properties.key_facts.first() {
535                lines.push(format!("- {}", escape_cli_text(fact)));
536            }
537        }
538        DetailLevel::Minimal => {
539            lines.push(format!(
540                "# {} | {} [{}]",
541                node.id,
542                escape_cli_text(&node.name),
543                node.r#type
544            ));
545        }
546    }
547    lines
548}
549
550fn render_node_link_lines(graph: &GraphFile, node: &Node, edge_cap: usize) -> Vec<String> {
551    let incident = incident_edges(graph, &node.id);
552    if incident.is_empty() {
553        return Vec::new();
554    }
555
556    let mut lines = Vec::new();
557    if incident.len() > 12 {
558        lines.push(format!("links: {} total", incident.len()));
559        let (out_summary, in_summary) = summarize_relations(&incident);
560        if !out_summary.is_empty() {
561            lines.push(format!("out: {out_summary}"));
562        }
563        if !in_summary.is_empty() {
564            lines.push(format!("in: {in_summary}"));
565        }
566    }
567
568    let shown = incident.len().min(edge_cap);
569    for edge in incident.into_iter().take(shown) {
570        let prefix = if edge.incoming { "<-" } else { "->" };
571        lines.extend(render_edge_lines(prefix, edge.edge, edge.related, false));
572    }
573    if edge_cap > 0 && incident_count(graph, &node.id) > shown {
574        lines.push(format!(
575            "... {} more links omitted",
576            incident_count(graph, &node.id) - shown
577        ));
578    }
579    lines
580}
581
582fn incident_count(graph: &GraphFile, node_id: &str) -> usize {
583    graph
584        .edges
585        .iter()
586        .filter(|edge| edge.source_id == node_id || edge.target_id == node_id)
587        .count()
588}
589
590struct IncidentEdge<'a> {
591    edge: &'a Edge,
592    related: &'a Node,
593    incoming: bool,
594}
595
596fn incident_edges<'a>(graph: &'a GraphFile, node_id: &str) -> Vec<IncidentEdge<'a>> {
597    let mut edges = Vec::new();
598    for edge in &graph.edges {
599        if edge.source_id == node_id {
600            if let Some(related) = graph.node_by_id(&edge.target_id) {
601                edges.push(IncidentEdge {
602                    edge,
603                    related,
604                    incoming: false,
605                });
606            }
607        } else if edge.target_id == node_id {
608            if let Some(related) = graph.node_by_id(&edge.source_id) {
609                edges.push(IncidentEdge {
610                    edge,
611                    related,
612                    incoming: true,
613                });
614            }
615        }
616    }
617    edges.sort_by(|left, right| {
618        right
619            .related
620            .properties
621            .importance
622            .cmp(&left.related.properties.importance)
623            .then_with(|| left.edge.relation.cmp(&right.edge.relation))
624            .then_with(|| left.related.id.cmp(&right.related.id))
625    });
626    edges
627}
628
629fn summarize_relations(edges: &[IncidentEdge<'_>]) -> (String, String) {
630    let mut out: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
631    let mut incoming: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
632
633    for edge in edges {
634        let bucket = if edge.incoming {
635            &mut incoming
636        } else {
637            &mut out
638        };
639        *bucket.entry(edge.edge.relation.clone()).or_insert(0) += 1;
640    }
641
642    (join_relation_counts(&out), join_relation_counts(&incoming))
643}
644
645fn join_relation_counts(counts: &std::collections::BTreeMap<String, usize>) -> String {
646    counts
647        .iter()
648        .take(3)
649        .map(|(relation, count)| format!("{relation} x{count}"))
650        .collect::<Vec<_>>()
651        .join(", ")
652}
653
654fn render_node_block(graph: &GraphFile, node: &Node, full: bool) -> String {
655    let mut lines = Vec::new();
656    lines.push(format!(
657        "# {} | {} [{}]",
658        node.id,
659        escape_cli_text(&node.name),
660        node.r#type
661    ));
662
663    if !node.properties.alias.is_empty() {
664        lines.push(format!(
665            "aka: {}",
666            node.properties
667                .alias
668                .iter()
669                .map(|alias| escape_cli_text(alias))
670                .collect::<Vec<_>>()
671                .join(", ")
672        ));
673    }
674    push_description_line(
675        &mut lines,
676        &node.properties.description,
677        if full { None } else { Some(200) },
678    );
679    if full {
680        if !node.properties.domain_area.is_empty() {
681            lines.push(format!(
682                "domain_area: {}",
683                escape_cli_text(&node.properties.domain_area)
684            ));
685        }
686        if !node.properties.provenance.is_empty() {
687            lines.push(format!(
688                "provenance: {}",
689                escape_cli_text(&node.properties.provenance)
690            ));
691        }
692        if let Some(confidence) = node.properties.confidence {
693            lines.push(format!("confidence: {confidence}"));
694        }
695        lines.push(format!("importance: {}", node.properties.importance));
696        if !node.properties.created_at.is_empty() {
697            lines.push(format!("created_at: {}", node.properties.created_at));
698        }
699    }
700
701    let facts_to_show = if full {
702        node.properties.key_facts.len()
703    } else {
704        node.properties.key_facts.len().min(2)
705    };
706    for fact in node.properties.key_facts.iter().take(facts_to_show) {
707        lines.push(format!("- {}", escape_cli_text(fact)));
708    }
709    let omitted = node
710        .properties
711        .key_facts
712        .len()
713        .saturating_sub(facts_to_show);
714    if omitted > 0 {
715        lines.push(format!("... {omitted} more facts omitted"));
716    }
717
718    if full {
719        if !node.source_files.is_empty() {
720            lines.push(format!(
721                "sources: {}",
722                node.source_files
723                    .iter()
724                    .map(|source| escape_cli_text(source))
725                    .collect::<Vec<_>>()
726                    .join(", ")
727            ));
728        }
729        push_feedback_lines(
730            &mut lines,
731            node.properties.feedback_score,
732            node.properties.feedback_count,
733            node.properties.feedback_last_ts_ms,
734            None,
735        );
736    }
737
738    let attached_notes: Vec<_> = graph
739        .notes
740        .iter()
741        .filter(|note| note.node_id == node.id)
742        .collect();
743    if full && !attached_notes.is_empty() {
744        lines.push(format!("notes: {}", attached_notes.len()));
745        for note in attached_notes {
746            lines.extend(render_attached_note_lines(note));
747        }
748    }
749
750    for edge in outgoing_edges(graph, &node.id, full) {
751        if let Some(target) = graph.node_by_id(&edge.target_id) {
752            lines.extend(render_edge_lines("->", edge, target, full));
753        }
754    }
755    for edge in incoming_edges(graph, &node.id, full) {
756        if let Some(source) = graph.node_by_id(&edge.source_id) {
757            lines.extend(render_edge_lines("<-", edge, source, full));
758        }
759    }
760
761    lines.join("\n")
762}
763
764fn outgoing_edges<'a>(graph: &'a GraphFile, node_id: &str, full: bool) -> Vec<&'a Edge> {
765    let mut edges: Vec<&Edge> = graph
766        .edges
767        .iter()
768        .filter(|edge| edge.source_id == node_id)
769        .collect();
770    edges.sort_by_key(|edge| (&edge.relation, &edge.target_id));
771    if !full {
772        edges.truncate(3);
773    }
774    edges
775}
776
777fn incoming_edges<'a>(graph: &'a GraphFile, node_id: &str, full: bool) -> Vec<&'a Edge> {
778    let mut edges: Vec<&Edge> = graph
779        .edges
780        .iter()
781        .filter(|edge| edge.target_id == node_id)
782        .collect();
783    edges.sort_by_key(|edge| (&edge.relation, &edge.source_id));
784    if !full {
785        edges.truncate(3);
786    }
787    edges
788}
789
790fn neighbor_nodes<'a>(graph: &'a GraphFile, node_id: &str) -> Vec<&'a Node> {
791    let mut seen = HashSet::new();
792    let mut nodes = Vec::new();
793
794    for edge in &graph.edges {
795        let related_id = if edge.source_id == node_id {
796            Some(edge.target_id.as_str())
797        } else if edge.target_id == node_id {
798            Some(edge.source_id.as_str())
799        } else {
800            None
801        };
802
803        if let Some(related_id) = related_id {
804            if seen.insert(related_id) {
805                if let Some(node) = graph.node_by_id(related_id) {
806                    nodes.push(node);
807                }
808            }
809        }
810    }
811
812    nodes.sort_by(|left, right| left.id.cmp(&right.id));
813    nodes
814}
815
816fn render_edge_lines(prefix: &str, edge: &Edge, related: &Node, full: bool) -> Vec<String> {
817    let (arrow, relation) = if edge.relation.starts_with("NOT_") {
818        (
819            format!("{prefix}!"),
820            edge.relation.trim_start_matches("NOT_"),
821        )
822    } else {
823        (prefix.to_owned(), edge.relation.as_str())
824    };
825
826    let mut line = format!(
827        "{arrow} {relation} | {} | {}",
828        related.id,
829        escape_cli_text(&related.name)
830    );
831    if !edge.properties.detail.is_empty() {
832        line.push_str(" | ");
833        let detail = escape_cli_text(&edge.properties.detail);
834        if full {
835            line.push_str(&detail);
836        } else {
837            line.push_str(&truncate(&detail, 80));
838        }
839    }
840    let mut lines = vec![line];
841    if full {
842        push_feedback_lines(
843            &mut lines,
844            edge.properties.feedback_score,
845            edge.properties.feedback_count,
846            edge.properties.feedback_last_ts_ms,
847            Some("edge_"),
848        );
849        if !edge.properties.valid_from.is_empty() {
850            lines.push(format!("edge_valid_from: {}", edge.properties.valid_from));
851        }
852        if !edge.properties.valid_to.is_empty() {
853            lines.push(format!("edge_valid_to: {}", edge.properties.valid_to));
854        }
855    }
856    lines
857}
858
859fn truncate(value: &str, max_len: usize) -> String {
860    let char_count = value.chars().count();
861    if char_count <= max_len {
862        return value.to_owned();
863    }
864    let truncated: String = value.chars().take(max_len.saturating_sub(3)).collect();
865    format!("{truncated}...")
866}
867
868fn escape_cli_text(value: &str) -> String {
869    let mut out = String::new();
870    for ch in value.chars() {
871        match ch {
872            '\\' => out.push_str("\\\\"),
873            '\n' => out.push_str("\\n"),
874            '\r' => out.push_str("\\r"),
875            '\t' => out.push_str("\\t"),
876            _ => out.push(ch),
877        }
878    }
879    out
880}
881
882fn push_description_line(lines: &mut Vec<String>, description: &str, max_len: Option<usize>) {
883    if description.is_empty() {
884        return;
885    }
886    let escaped = escape_cli_text(description);
887    let rendered = match max_len {
888        Some(limit) => truncate(&escaped, limit),
889        None => escaped,
890    };
891    lines.push(format!("desc: {rendered}"));
892}
893
894fn push_feedback_lines(
895    lines: &mut Vec<String>,
896    score: f64,
897    count: u64,
898    last_ts_ms: Option<u64>,
899    prefix: Option<&str>,
900) {
901    let prefix = prefix.unwrap_or("");
902    if score != 0.0 {
903        lines.push(format!("{prefix}feedback_score: {score}"));
904    }
905    if count != 0 {
906        lines.push(format!("{prefix}feedback_count: {count}"));
907    }
908    if let Some(ts) = last_ts_ms {
909        lines.push(format!("{prefix}feedback_last_ts_ms: {ts}"));
910    }
911}
912
913fn render_attached_note_lines(note: &crate::graph::Note) -> Vec<String> {
914    let mut lines = vec![format!("! {}", note.id)];
915    if !note.body.is_empty() {
916        lines.push(format!("note_body: {}", escape_cli_text(&note.body)));
917    }
918    if !note.tags.is_empty() {
919        lines.push(format!(
920            "note_tags: {}",
921            note.tags
922                .iter()
923                .map(|tag| escape_cli_text(tag))
924                .collect::<Vec<_>>()
925                .join(", ")
926        ));
927    }
928    if !note.author.is_empty() {
929        lines.push(format!("note_author: {}", escape_cli_text(&note.author)));
930    }
931    if !note.created_at.is_empty() {
932        lines.push(format!("note_created_at: {}", note.created_at));
933    }
934    if !note.provenance.is_empty() {
935        lines.push(format!(
936            "note_provenance: {}",
937            escape_cli_text(&note.provenance)
938        ));
939    }
940    if !note.source_files.is_empty() {
941        lines.push(format!(
942            "note_sources: {}",
943            note.source_files
944                .iter()
945                .map(|source| escape_cli_text(source))
946                .collect::<Vec<_>>()
947                .join(", ")
948        ));
949    }
950    lines
951}
952
953fn find_matches_with_index<'a>(
954    graph: &'a GraphFile,
955    query: &str,
956    limit: usize,
957    include_features: bool,
958    mode: FindMode,
959    index: Option<&Bm25Index>,
960) -> Vec<&'a Node> {
961    let mut matches = find_all_matches_with_index(graph, query, include_features, mode, index);
962    matches.truncate(limit);
963    matches
964}
965
966fn find_all_matches_with_index<'a>(
967    graph: &'a GraphFile,
968    query: &str,
969    include_features: bool,
970    mode: FindMode,
971    index: Option<&Bm25Index>,
972) -> Vec<&'a Node> {
973    let mut scored: Vec<(i64, Reverse<&str>, &'a Node)> = match mode {
974        FindMode::Fuzzy => {
975            let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
976            let mut matcher = Matcher::new(Config::DEFAULT);
977            graph
978                .nodes
979                .iter()
980                .filter(|node| include_features || node.r#type != "Feature")
981                .filter_map(|node| {
982                    score_node(graph, node, query, &pattern, &mut matcher).map(|score| {
983                        let base = score as i64;
984                        let boost = feedback_boost(node);
985                        (base + boost, Reverse(node.id.as_str()), node)
986                    })
987                })
988                .collect()
989        }
990        FindMode::Bm25 => score_bm25(graph, query, include_features, index),
991    };
992
993    scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
994    scored.into_iter().map(|(_, _, node)| node).collect()
995}
996
997fn feedback_boost(node: &Node) -> i64 {
998    let count = node.properties.feedback_count as f64;
999    if count <= 0.0 {
1000        return 0;
1001    }
1002    let avg = node.properties.feedback_score / count;
1003    let confidence = (count.ln_1p() / 3.0).min(1.0);
1004    let scaled = avg * 200.0 * confidence;
1005    scaled.clamp(-300.0, 300.0).round() as i64
1006}
1007
1008fn score_bm25<'a>(
1009    graph: &'a GraphFile,
1010    query: &str,
1011    include_features: bool,
1012    index: Option<&Bm25Index>,
1013) -> Vec<(i64, Reverse<&'a str>, &'a Node)> {
1014    let terms = tokenize(query);
1015    if terms.is_empty() {
1016        return Vec::new();
1017    }
1018
1019    if let Some(idx) = index {
1020        let results = idx.search(&terms, graph);
1021        return results
1022            .into_iter()
1023            .filter_map(|(node_id, score)| {
1024                let node = graph.node_by_id(&node_id)?;
1025                if !include_features && node.r#type == "Feature" {
1026                    return None;
1027                }
1028                let boost = feedback_boost(node) as f64;
1029                let combined = (score as f64 * 100.0 + boost).round() as i64;
1030                Some((combined, Reverse(node.id.as_str()), node))
1031            })
1032            .collect();
1033    }
1034
1035    let mut docs: Vec<(&'a Node, Vec<String>)> = graph
1036        .nodes
1037        .iter()
1038        .filter(|node| include_features || node.r#type != "Feature")
1039        .map(|node| (node, tokenize(&node_document_text(graph, node))))
1040        .collect();
1041
1042    if docs.is_empty() {
1043        return Vec::new();
1044    }
1045
1046    let mut df: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
1047    for term in &terms {
1048        let mut count = 0usize;
1049        for (_, tokens) in &docs {
1050            if tokens.iter().any(|t| t == term) {
1051                count += 1;
1052            }
1053        }
1054        df.insert(term.as_str(), count);
1055    }
1056
1057    let total_docs = docs.len() as f64;
1058    let avgdl = docs
1059        .iter()
1060        .map(|(_, tokens)| tokens.len() as f64)
1061        .sum::<f64>()
1062        / total_docs;
1063
1064    let mut scored = Vec::new();
1065
1066    for (node, tokens) in docs.drain(..) {
1067        let dl = tokens.len() as f64;
1068        if dl == 0.0 {
1069            continue;
1070        }
1071        let mut score = 0.0f64;
1072        for term in &terms {
1073            let tf = tokens.iter().filter(|t| *t == term).count() as f64;
1074            if tf == 0.0 {
1075                continue;
1076            }
1077            let df_t = *df.get(term.as_str()).unwrap_or(&0) as f64;
1078            let idf = (1.0 + (total_docs - df_t + 0.5) / (df_t + 0.5)).ln();
1079            let denom = tf + BM25_K1 * (1.0 - BM25_B + BM25_B * (dl / avgdl));
1080            score += idf * (tf * (BM25_K1 + 1.0) / denom);
1081        }
1082        if score > 0.0 {
1083            let boost = feedback_boost(node) as f64;
1084            let combined = score * 100.0 + boost;
1085            scored.push((combined.round() as i64, Reverse(node.id.as_str()), node));
1086        }
1087    }
1088
1089    scored
1090}
1091
1092fn node_document_text(graph: &GraphFile, node: &Node) -> String {
1093    let mut out = String::new();
1094    push_field(&mut out, &node.id);
1095    push_field(&mut out, &node.name);
1096    push_field(&mut out, &node.properties.description);
1097    for alias in &node.properties.alias {
1098        push_field(&mut out, alias);
1099    }
1100    for fact in &node.properties.key_facts {
1101        push_field(&mut out, fact);
1102    }
1103    for note in graph.notes.iter().filter(|note| note.node_id == node.id) {
1104        push_field(&mut out, &note.body);
1105        for tag in &note.tags {
1106            push_field(&mut out, tag);
1107        }
1108    }
1109    for neighbor in neighbor_nodes(graph, &node.id) {
1110        push_field(&mut out, &neighbor.id);
1111        push_field(&mut out, &neighbor.name);
1112        push_field(&mut out, &neighbor.properties.description);
1113        for alias in &neighbor.properties.alias {
1114            push_field(&mut out, alias);
1115        }
1116    }
1117    out
1118}
1119
1120fn push_field(target: &mut String, value: &str) {
1121    if value.is_empty() {
1122        return;
1123    }
1124    if !target.is_empty() {
1125        target.push(' ');
1126    }
1127    target.push_str(value);
1128}
1129
1130fn tokenize(text: &str) -> Vec<String> {
1131    let mut tokens = Vec::new();
1132    let mut current = String::new();
1133    for ch in text.chars() {
1134        if ch.is_alphanumeric() {
1135            current.push(ch.to_ascii_lowercase());
1136        } else if !current.is_empty() {
1137            tokens.push(current.clone());
1138            current.clear();
1139        }
1140    }
1141    if !current.is_empty() {
1142        tokens.push(current);
1143    }
1144    tokens
1145}
1146
1147fn score_node(
1148    graph: &GraphFile,
1149    node: &Node,
1150    query: &str,
1151    pattern: &Pattern,
1152    matcher: &mut Matcher,
1153) -> Option<u32> {
1154    let mut total = 0;
1155    let mut primary_hits = 0;
1156
1157    let id_score = score_primary_field(query, pattern, matcher, &node.id, 4);
1158    if id_score > 0 {
1159        primary_hits += 1;
1160    }
1161    total += id_score;
1162
1163    let name_score = score_primary_field(query, pattern, matcher, &node.name, 3);
1164    if name_score > 0 {
1165        primary_hits += 1;
1166    }
1167    total += name_score;
1168
1169    for alias in &node.properties.alias {
1170        let alias_score = score_primary_field(query, pattern, matcher, alias, 3);
1171        if alias_score > 0 {
1172            primary_hits += 1;
1173        }
1174        total += alias_score;
1175    }
1176
1177    if primary_hits > 0 {
1178        total += score_secondary_field(query, pattern, matcher, &node.properties.description, 1);
1179    } else {
1180        total += score_neighbor_context(graph, node, query, pattern, matcher);
1181    }
1182
1183    (total > 0).then_some(total)
1184}
1185
1186fn score_neighbor_context(
1187    graph: &GraphFile,
1188    node: &Node,
1189    query: &str,
1190    pattern: &Pattern,
1191    matcher: &mut Matcher,
1192) -> u32 {
1193    let mut best = 0;
1194
1195    for neighbor in neighbor_nodes(graph, &node.id) {
1196        let mut score = score_secondary_field(query, pattern, matcher, &neighbor.id, 1)
1197            + score_secondary_field(query, pattern, matcher, &neighbor.name, 1)
1198            + score_secondary_field(query, pattern, matcher, &neighbor.properties.description, 1);
1199
1200        for alias in &neighbor.properties.alias {
1201            score += score_secondary_field(query, pattern, matcher, alias, 1);
1202        }
1203
1204        best = best.max(score);
1205    }
1206
1207    best
1208}
1209
1210fn score_field(pattern: &Pattern, matcher: &mut Matcher, value: &str) -> Option<u32> {
1211    if value.is_empty() {
1212        return None;
1213    }
1214    let mut buf = Vec::new();
1215    let haystack = Utf32Str::new(value, &mut buf);
1216    pattern.score(haystack, matcher)
1217}
1218
1219fn score_primary_field(
1220    query: &str,
1221    pattern: &Pattern,
1222    matcher: &mut Matcher,
1223    value: &str,
1224    weight: u32,
1225) -> u32 {
1226    let bonus = textual_bonus(query, value);
1227    if bonus == 0 {
1228        return 0;
1229    }
1230    let fuzzy = score_field(pattern, matcher, value).unwrap_or(0);
1231    (fuzzy + bonus) * weight
1232}
1233
1234fn score_secondary_field(
1235    query: &str,
1236    pattern: &Pattern,
1237    matcher: &mut Matcher,
1238    value: &str,
1239    weight: u32,
1240) -> u32 {
1241    let bonus = textual_bonus(query, value);
1242    if bonus == 0 {
1243        return 0;
1244    }
1245    let fuzzy = score_field(pattern, matcher, value).unwrap_or(0);
1246    (fuzzy + bonus / 2) * weight
1247}
1248
1249fn textual_bonus(query: &str, value: &str) -> u32 {
1250    let query = query.trim().to_lowercase();
1251    let value = value.to_lowercase();
1252
1253    if value == query {
1254        return 400;
1255    }
1256    if value.contains(&query) {
1257        return 200;
1258    }
1259
1260    query
1261        .split_whitespace()
1262        .map(|token| {
1263            if value.contains(token) {
1264                80
1265            } else if is_subsequence(token, &value) {
1266                40
1267            } else {
1268                0
1269            }
1270        })
1271        .sum()
1272}
1273
1274fn is_subsequence(needle: &str, haystack: &str) -> bool {
1275    if needle.is_empty() {
1276        return false;
1277    }
1278
1279    let mut chars = needle.chars();
1280    let mut current = match chars.next() {
1281        Some(ch) => ch,
1282        None => return false,
1283    };
1284
1285    for ch in haystack.chars() {
1286        if ch == current {
1287            match chars.next() {
1288                Some(next) => current = next,
1289                None => return true,
1290            }
1291        }
1292    }
1293
1294    false
1295}