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(¬e.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(¬e.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(¬e.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, ¬e.body);
1105 for tag in ¬e.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}