1use crate::error::Result;
14use crate::fts::{is_generic_topic, prepare_search_query, tokenize_query};
15use crate::query::{QueryOptions, RankedHit};
16use crate::storage::Database;
17use crate::types::{ContextBundle, ContextNode, ContextRole, Node, NodeType};
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20use std::path::Path;
21use std::time::Instant;
22
23const MAX_EXCERPT_CHARS: usize = 900;
25const MAX_SYMBOL_EXCERPT_CHARS: usize = 280;
27const MAX_PACKED_SYMBOL_NEIGHBORS: usize = 8;
29
30#[derive(Debug, Clone)]
32pub struct ContextOptions {
33 pub max_tokens: usize,
35 pub hop_depth: usize,
37 pub max_seeds: usize,
39 pub max_nodes: usize,
41 pub hop_decay: f32,
43 pub no_symbols: bool,
45 pub include_types: Vec<crate::types::NodeType>,
47 pub exclude_types: Vec<crate::types::NodeType>,
49 pub hop_to_symbols: bool,
52 pub hop_from_docs_only: bool,
54 pub include_excerpts: bool,
56}
57
58impl Default for ContextOptions {
59 fn default() -> Self {
60 Self {
61 max_tokens: 2048,
62 hop_depth: 1,
63 max_seeds: 8,
64 max_nodes: 24,
65 hop_decay: 0.65,
66 no_symbols: true,
68 include_types: Vec::new(),
69 exclude_types: Vec::new(),
70 hop_to_symbols: true,
71 hop_from_docs_only: true,
72 include_excerpts: true,
73 }
74 }
75}
76
77impl ContextOptions {
78 pub fn agent() -> Self {
80 Self::default()
81 }
82
83 pub fn with_symbols(mut self) -> Self {
85 self.no_symbols = false;
86 self
87 }
88
89 fn seed_query_opts(&self) -> QueryOptions {
90 QueryOptions {
91 limit: self.max_seeds.saturating_mul(2).max(8),
92 no_symbols: self.no_symbols,
93 include_types: self.include_types.clone(),
94 exclude_types: self.exclude_types.clone(),
95 ..QueryOptions::default()
96 }
97 }
98
99 fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
100 use crate::types::NodeType;
101 if !self.include_types.is_empty() && !self.include_types.contains(ty) {
102 if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
104 return false;
105 }
106 }
107 if self.exclude_types.contains(ty) {
108 return false;
109 }
110 if *ty == NodeType::Symbol {
111 if role == ContextRole::Seed && self.no_symbols {
112 return false;
113 }
114 if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
115 return false;
116 }
117 }
118 true
119 }
120}
121
122#[derive(Debug, Clone)]
124struct Candidate {
125 node: Node,
126 score: f32,
127 role: ContextRole,
128 hop: u8,
129 excerpt: Option<String>,
130}
131
132pub fn assemble_context(
142 db: &Database,
143 brain_dir: &Path,
144 prompt: &str,
145 opts: &ContextOptions,
146) -> Result<ContextBundle> {
147 let start = Instant::now();
148 let char_budget = opts.max_tokens.saturating_mul(4).max(256);
149 let query_tokens = prepare_search_query(prompt)
150 .map(|p| p.tokens)
151 .unwrap_or_else(|_| tokenize_query(prompt));
152
153 let qopts = opts.seed_query_opts();
154 let mut seeds = db.search_ranked(prompt, &qopts)?;
155 let generic = is_generic_topic(&query_tokens);
156 if seeds.is_empty() || generic {
158 inject_hub_seeds(db, &mut seeds, opts)?;
159 }
160
161 let mut candidates: Vec<Candidate> = Vec::new();
162 let mut seed_ids: HashSet<String> = HashSet::new();
163
164 for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
165 if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
166 continue;
167 }
168 if is_template_stub(&hit.node) {
170 continue;
171 }
172 seed_ids.insert(hit.node.id.clone());
173 let mut score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
175 if is_generated_path(&hit.node) {
177 score *= 0.85;
178 }
179 if hit.node.node_type == NodeType::Adr {
180 score *= 1.08;
181 }
182 let excerpt = if opts.include_excerpts {
183 load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
184 } else {
185 None
186 };
187 candidates.push(Candidate {
188 node: hit.node,
189 score,
190 role: ContextRole::Seed,
191 hop: 0,
192 excerpt,
193 });
194 }
195
196 if candidates.is_empty() {
198 let mut hub_hits = Vec::new();
199 inject_hub_seeds(db, &mut hub_hits, opts)?;
200 for hit in hub_hits.into_iter().take(4) {
201 if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
202 continue;
203 }
204 if seed_ids.contains(&hit.node.id) {
205 continue;
206 }
207 seed_ids.insert(hit.node.id.clone());
208 let excerpt = if opts.include_excerpts {
209 load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
210 } else {
211 None
212 };
213 candidates.push(Candidate {
214 node: hit.node,
215 score: hit.score,
216 role: ContextRole::Seed,
217 hop: 0,
218 excerpt,
219 });
220 }
221 }
222
223 let mut graph_nodes = 0usize;
224 let mut graph_edges = 0usize;
225 let mut neighbor_ids: Vec<String> = Vec::new();
226
227 #[cfg(feature = "mmap")]
228 if opts.hop_depth > 0 {
229 let mmap_path = brain_dir.join("graph.mmap");
230 if mmap_path.exists() {
231 if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
232 graph_nodes = graph.node_count;
233 graph_edges = graph.edge_count;
234
235 let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
236
237 for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
238 if opts.hop_from_docs_only && cand.node.node_type == NodeType::Symbol {
239 continue;
240 }
241 if let Some(idx) = graph.index_of(&cand.node.id) {
242 for (nidx, edge_w) in
243 graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
244 {
245 if let Some(id) = graph.node_id(nidx as usize) {
246 if seed_ids.contains(id) {
247 continue;
248 }
249 let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
250 1
251 } else {
252 2
253 };
254 let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
255 best_neighbor
256 .entry(id.to_string())
257 .and_modify(|(s, h)| {
258 if nscore > *s {
259 *s = nscore;
260 *h = hop;
261 }
262 })
263 .or_insert((nscore, hop));
264 }
265 }
266 }
267 }
268
269 let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
270 neigh_sorted.sort_by(|a, b| {
271 b.1 .0
272 .partial_cmp(&a.1 .0)
273 .unwrap_or(std::cmp::Ordering::Equal)
274 });
275
276 for (id, (score, hop)) in neigh_sorted {
277 neighbor_ids.push(id.clone());
278 if let Some(node) = db.get_node(&id)? {
279 if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
280 continue;
281 }
282 let mut nscore = score;
283 if node.node_type == NodeType::Symbol {
284 match symbol_neighbor_quality(&node, &query_tokens) {
285 SymbolQuality::Drop => continue,
286 SymbolQuality::Keep { boost } => nscore *= boost,
287 }
288 }
289 let max_ex = if node.node_type == NodeType::Symbol {
290 MAX_SYMBOL_EXCERPT_CHARS
291 } else {
292 MAX_EXCERPT_CHARS
293 };
294 let excerpt = if opts.include_excerpts {
295 load_excerpt(db, &node, max_ex)
296 } else {
297 None
298 };
299 candidates.push(Candidate {
300 node,
301 score: nscore,
302 role: ContextRole::Neighbor,
303 hop,
304 excerpt,
305 });
306 }
307 }
308 }
309 }
310 }
311
312 candidates.sort_by(|a, b| {
315 pack_rank(b)
316 .partial_cmp(&pack_rank(a))
317 .unwrap_or(std::cmp::Ordering::Equal)
318 });
319
320 let mut packed: Vec<ContextNode> = Vec::new();
321 let mut used_chars = estimate_overhead(prompt);
322 let mut seen = HashSet::new();
323 let mut symbol_neighbors = 0usize;
324 let mut packed_excerpts: Vec<String> = Vec::new();
325
326 for cand in candidates {
327 if packed.len() >= opts.max_nodes {
328 break;
329 }
330 if !seen.insert(cand.node.id.clone()) {
331 continue;
332 }
333 if cand.role == ContextRole::Neighbor
334 && cand.node.node_type == NodeType::Symbol
335 && symbol_neighbors >= MAX_PACKED_SYMBOL_NEIGHBORS
336 {
337 continue;
338 }
339 if is_readme_family(&cand.node.id)
341 && packed.iter().any(|n| is_readme_family(&n.id))
342 {
343 continue;
344 }
345 if let Some(ex) = &cand.excerpt {
347 if packed_excerpts
348 .iter()
349 .any(|prev| excerpt_jaccard(prev, ex) > 0.55)
350 {
351 continue;
352 }
353 }
354 let ctx_node = to_context_node(&cand);
355 let cost = estimate_node_chars(&ctx_node);
356 if used_chars + cost > char_budget && !packed.is_empty() {
357 if cand.role == ContextRole::Neighbor {
359 continue;
360 }
361 if packed.iter().any(|n| n.role == ContextRole::Seed) {
362 continue;
363 }
364 }
365 if used_chars + cost > char_budget && !packed.is_empty() {
366 break;
367 }
368 used_chars += cost;
369 if cand.role == ContextRole::Neighbor && cand.node.node_type == NodeType::Symbol {
370 symbol_neighbors += 1;
371 }
372 if let Some(ex) = &ctx_node.excerpt {
373 packed_excerpts.push(ex.clone());
374 }
375 packed.push(ctx_node);
376 }
377
378 neighbor_ids.retain(|id| !seed_ids.contains(id));
379 neighbor_ids.truncate(48);
380
381 let latency_us = start.elapsed().as_micros() as u64;
382 let tokens_used = used_chars.div_ceil(4);
383
384 Ok(ContextBundle {
385 prompt: prompt.to_string(),
386 max_tokens: opts.max_tokens,
387 tokens_used,
388 nodes: packed,
389 neighbor_ids,
390 latency_us,
391 graph_nodes,
392 graph_edges,
393 })
394}
395
396enum SymbolQuality {
397 Drop,
398 Keep { boost: f32 },
399}
400
401fn symbol_neighbor_quality(node: &Node, query_tokens: &[String]) -> SymbolQuality {
403 let title = node.title.to_lowercase();
404 let id = node.id.to_lowercase();
405 let leaf = id.rsplit('/').next().unwrap_or(&id);
406
407 for t in query_tokens {
409 if title.contains(t.as_str()) || id.contains(t.as_str()) {
410 return SymbolQuality::Keep { boost: 1.8 };
411 }
412 }
413
414 if title.contains("::") || leaf.contains("::") {
416 return SymbolQuality::Keep { boost: 1.15 };
417 }
418
419 let name = title.split("::").last().unwrap_or(&title);
421 if name.len() <= 2 {
422 return SymbolQuality::Drop;
423 }
424 if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() <= 12 {
425 return SymbolQuality::Drop;
427 }
428 if !name.contains('_') && !title.contains("::") && name.len() <= 8 {
430 let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
432 let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
433 if has_upper && has_lower {
434 return SymbolQuality::Keep { boost: 1.0 };
435 }
436 return SymbolQuality::Drop;
437 }
438
439 SymbolQuality::Keep { boost: 1.0 }
440}
441
442fn inject_hub_seeds(
444 db: &Database,
445 seeds: &mut Vec<RankedHit>,
446 opts: &ContextOptions,
447) -> Result<()> {
448 let existing: HashSet<String> = seeds.iter().map(|h| h.node.id.clone()).collect();
449 let hubs = [
450 ("readme", 4.5_f32),
451 ("docs/goals/from-readme", 3.8),
452 ("docs/implementation/module-map.generated", 2.2),
453 ("docs/goals/readme", 1.5),
454 ];
455 for (id, score) in hubs {
456 if existing.contains(id) {
457 continue;
458 }
459 if let Some(node) = db.get_node(id)? {
460 if !opts.allows_pack(&node.node_type, ContextRole::Seed) {
461 continue;
462 }
463 if is_template_stub(&node) {
464 continue;
465 }
466 seeds.push(RankedHit {
467 node,
468 score,
469 reasons: vec!["hub-fallback".into()],
470 });
471 }
472 }
473 seeds.sort_by(|a, b| {
475 b.score
476 .partial_cmp(&a.score)
477 .unwrap_or(std::cmp::Ordering::Equal)
478 });
479 Ok(())
480}
481
482fn is_template_stub(node: &Node) -> bool {
483 let id = node.id.to_lowercase();
484 let path = node.file_path.as_deref().unwrap_or("").to_lowercase();
485 let title = node.title.to_lowercase();
486 id.contains("template")
487 || path.ends_with("template.md")
488 || title.contains("template")
489 || title == "adr template"
490}
491
492fn is_generated_path(node: &Node) -> bool {
493 let path = node.file_path.as_deref().unwrap_or("");
494 path.contains("from-readme")
495 || path.contains(".generated.")
496 || path.ends_with("module-map.generated.md")
497}
498
499fn is_readme_family(id: &str) -> bool {
500 id == "readme" || id.contains("from-readme")
501}
502
503fn excerpt_jaccard(a: &str, b: &str) -> f32 {
504 let ta: HashSet<&str> = a
505 .split_whitespace()
506 .filter(|w| w.len() > 2)
507 .collect();
508 let tb: HashSet<&str> = b
509 .split_whitespace()
510 .filter(|w| w.len() > 2)
511 .collect();
512 if ta.is_empty() || tb.is_empty() {
513 return 0.0;
514 }
515 let inter = ta.intersection(&tb).count() as f32;
516 let uni = ta.union(&tb).count() as f32;
517 if uni == 0.0 {
518 0.0
519 } else {
520 inter / uni
521 }
522}
523
524fn pack_rank(c: &Candidate) -> f32 {
525 let role = match c.role {
526 ContextRole::Seed => 3.0,
527 ContextRole::Neighbor => 0.0,
528 };
529 let ty = match c.node.node_type {
530 NodeType::Adr => 2.5,
531 NodeType::Goal => 2.0,
532 NodeType::EdgeCase => 1.8,
533 NodeType::Analysis => 1.65,
534 NodeType::Concept => 1.4,
535 NodeType::Reference => 1.2,
536 NodeType::Alternative => 1.1,
537 NodeType::Symbol => 0.0,
538 };
539 c.score + role + ty
541}
542
543fn load_excerpt(db: &Database, node: &Node, max_chars: usize) -> Option<String> {
544 let raw = db
545 .get_fts_content(&node.id)
546 .ok()
547 .flatten()
548 .filter(|s| !s.trim().is_empty())
549 .or_else(|| node.summary.clone().filter(|s| !s.trim().is_empty()))?;
550 let cleaned = strip_yaml_frontmatter(&raw);
551 Some(truncate_excerpt(cleaned, max_chars))
552}
553
554fn strip_yaml_frontmatter(s: &str) -> &str {
556 let t = s.trim_start();
557 if !t.starts_with("---") {
558 return s.trim();
559 }
560 let after_open = &t[3..];
561 let body = after_open.strip_prefix('\n').unwrap_or(after_open);
563 if let Some(idx) = body.find("\n---") {
564 let rest = &body[idx + 4..];
565 return rest.trim_start_matches(['\r', '\n']).trim();
566 }
567 s.trim()
568}
569
570fn truncate_excerpt(s: &str, max_chars: usize) -> String {
571 let s = s.trim();
572 if s.chars().count() <= max_chars {
573 return s.to_string();
574 }
575 let mut out = String::new();
576 for (i, ch) in s.chars().enumerate() {
577 if i >= max_chars.saturating_sub(1) {
578 break;
579 }
580 out.push(ch);
581 }
582 out.push('…');
583 out
584}
585
586fn to_context_node(c: &Candidate) -> ContextNode {
587 ContextNode {
588 id: c.node.id.clone(),
589 node_type: c.node.node_type.clone(),
590 title: c.node.title.clone(),
591 summary: c.node.summary.clone(),
592 excerpt: c.excerpt.clone(),
593 file_path: c.node.file_path.clone(),
594 score_hint: c.score,
595 role: c.role,
596 hop: c.hop,
597 }
598}
599
600fn estimate_overhead(prompt: &str) -> usize {
601 120 + prompt.len()
602}
603
604fn estimate_node_chars(n: &ContextNode) -> usize {
605 let mut c = n.id.len() + n.title.len() + 48;
606 if let Some(s) = &n.summary {
607 c += s.len();
608 }
609 if let Some(ex) = &n.excerpt {
610 c += ex.len();
611 }
612 if let Some(p) = &n.file_path {
613 c += p.len();
614 }
615 c
616}
617
618pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
620 hits.into_iter().map(|h| h.node).collect()
621}
622
623#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct WorkspaceHit {
629 pub workspace: String,
631 pub hit: RankedHit,
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::brain::Brain;
639 use tempfile::tempdir;
640
641 #[test]
642 fn context_includes_graph_neighbors_within_budget() {
643 let dir = tempdir().unwrap();
644 let docs = dir.path().join("docs");
645 std::fs::create_dir_all(&docs).unwrap();
646 std::fs::write(
648 docs.join("alpha.md"),
649 "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
650 )
651 .unwrap();
652 std::fs::write(
653 docs.join("beta.md"),
654 "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
655 )
656 .unwrap();
657
658 let mut brain = Brain::create(dir.path()).unwrap();
659 brain.sync().unwrap();
660
661 let opts = ContextOptions {
662 max_tokens: 2048,
663 hop_depth: 1,
664 max_seeds: 4,
665 max_nodes: 12,
666 hop_decay: 0.7,
667 ..ContextOptions::default()
668 };
669 let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
670 .unwrap();
671 assert!(!ctx.nodes.is_empty());
672 assert!(ctx.tokens_used > 0);
673 assert!(ctx.tokens_used <= opts.max_tokens + 64); let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
677 || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
678 assert!(
679 has_beta,
680 "expected beta neighbor via graph; nodes={:?} neigh={:?}",
681 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
682 ctx.neighbor_ids
683 );
684 }
685
686 #[test]
687 fn tight_budget_limits_nodes() {
688 let dir = tempdir().unwrap();
689 let docs = dir.path().join("docs");
690 std::fs::create_dir_all(&docs).unwrap();
691 for i in 0..8 {
692 std::fs::write(
693 docs.join(format!("n{i}.md")),
694 format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
695 )
696 .unwrap();
697 }
698 let mut brain = Brain::create(dir.path()).unwrap();
699 brain.sync().unwrap();
700 let opts = ContextOptions {
701 max_tokens: 40, hop_depth: 0,
703 max_seeds: 8,
704 max_nodes: 20,
705 hop_decay: 0.5,
706 ..ContextOptions::default()
707 };
708 let ctx =
709 assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
710 assert!(!ctx.nodes.is_empty());
711 assert!(ctx.nodes.len() < 8, "budget should clip nodes");
712 }
713
714 #[test]
715 fn natural_language_prompt_seeds_and_packs_excerpt() {
716 let dir = tempdir().unwrap();
717 std::fs::write(
718 dir.path().join("README.md"),
719 "# demotool\n\nLightweight **egui** explorer powered by DuckDB.\n\
720 Inspired by Duckling but avoids Tauri/WebView.\n",
721 )
722 .unwrap();
723 let mut brain = Brain::create(dir.path()).unwrap();
724 brain.sync().unwrap();
725
726 let ctx = assemble_context(
727 brain.database(),
728 brain.brain_dir(),
729 "why egui not tauri",
730 &ContextOptions::default(),
731 )
732 .unwrap();
733 assert!(
734 !ctx.nodes.is_empty(),
735 "expected seeds for NL prompt; packed=0"
736 );
737 let has_excerpt = ctx.nodes.iter().any(|n| {
738 n.excerpt
739 .as_ref()
740 .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("tauri"))
741 .unwrap_or(false)
742 });
743 assert!(
744 has_excerpt,
745 "expected body excerpt mentioning egui/tauri; nodes={:?}",
746 ctx.nodes
747 .iter()
748 .map(|n| (&n.id, &n.excerpt))
749 .collect::<Vec<_>>()
750 );
751 }
752
753 #[test]
754 fn generic_overview_prompt_falls_back_to_hub() {
755 let dir = tempdir().unwrap();
756 std::fs::write(
757 dir.path().join("README.md"),
758 "# demotool\n\nNative egui + DuckDB CLI. Avoids Tauri.\n",
759 )
760 .unwrap();
761 let mut brain = Brain::create(dir.path()).unwrap();
762 brain.sync().unwrap();
763
764 for prompt in [
765 "summarize architecture",
766 "what is this project about",
767 "give an overview",
768 ] {
769 let ctx = assemble_context(
770 brain.database(),
771 brain.brain_dir(),
772 prompt,
773 &ContextOptions::default(),
774 )
775 .unwrap();
776 assert!(
777 !ctx.nodes.is_empty(),
778 "expected hub fallback for `{prompt}`; packed=0"
779 );
780 let has_hub = ctx.nodes.iter().any(|n| {
781 n.id == "readme"
782 || n.id.contains("from-readme")
783 || n.excerpt
784 .as_ref()
785 .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("duckdb"))
786 .unwrap_or(false)
787 });
788 assert!(
789 has_hub,
790 "expected README hub content for `{prompt}`; nodes={:?}",
791 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
792 );
793 }
794 }
795
796 #[test]
797 fn strips_frontmatter_from_excerpts() {
798 let dir = tempdir().unwrap();
799 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
800 std::fs::write(
801 dir.path().join("docs/a.md"),
802 "---\ntags: [x]\nnode_type: concept\n---\n# Alpha\n\nBody about egui.\n",
803 )
804 .unwrap();
805 let mut brain = Brain::create(dir.path()).unwrap();
806 brain.sync().unwrap();
807 let ctx = assemble_context(
808 brain.database(),
809 brain.brain_dir(),
810 "egui",
811 &ContextOptions {
812 hop_depth: 0,
813 ..ContextOptions::default()
814 },
815 )
816 .unwrap();
817 let ex = ctx
818 .nodes
819 .iter()
820 .find_map(|n| n.excerpt.as_ref())
821 .expect("excerpt");
822 assert!(
823 !ex.trim_start().starts_with("---"),
824 "frontmatter leaked into excerpt: {ex}"
825 );
826 assert!(ex.to_lowercase().contains("egui") || ex.contains("Alpha"));
827 }
828
829 #[test]
830 fn prefers_adr_seed_over_symbol_neighbor() {
831 let dir = tempdir().unwrap();
832 std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
833 std::fs::create_dir_all(dir.path().join("src")).unwrap();
834 std::fs::write(
835 dir.path().join("Cargo.toml"),
836 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
837 )
838 .unwrap();
839 std::fs::write(
840 dir.path().join("src/lib.rs"),
841 "/// Run SQL via duckdb CLI.\npub fn run_sql() {}\n",
842 )
843 .unwrap();
844 std::fs::write(
845 dir.path().join("docs/adr/sql.md"),
846 "---\nnode_type: adr\n---\n# SQL via duckdb\n\nUse symbol:run_sql for SQL execution via duckdb CLI.\n",
847 )
848 .unwrap();
849 let mut brain = Brain::create(dir.path()).unwrap();
850 brain.sync().unwrap();
851 let _ = brain.sync().unwrap(); let ctx = assemble_context(
853 brain.database(),
854 brain.brain_dir(),
855 "how does SQL execution work",
856 &ContextOptions::default(),
857 )
858 .unwrap();
859 assert!(!ctx.nodes.is_empty());
860 let first = &ctx.nodes[0];
861 assert_eq!(
862 first.node_type,
863 NodeType::Adr,
864 "expected ADR first, got {:?} id={}",
865 first.node_type,
866 first.id
867 );
868 }
869
870 #[test]
871 fn dedups_near_identical_excerpts() {
872 let dir = tempdir().unwrap();
873 let body = "Lightweight egui explorer with DuckDB CLI. Avoids Tauri completely.\n".repeat(3);
874 std::fs::write(
875 dir.path().join("README.md"),
876 format!("# demotool\n\n{body}"),
877 )
878 .unwrap();
879 std::fs::create_dir_all(dir.path().join("docs/goals")).unwrap();
880 std::fs::write(
881 dir.path().join("docs/goals/from-readme.md"),
882 format!(
883 "---\nnode_type: goal\n---\n# Goals harvested from README\n\n{body}"
884 ),
885 )
886 .unwrap();
887 let mut brain = Brain::create(dir.path()).unwrap();
888 brain.sync().unwrap();
889 let ctx = assemble_context(
890 brain.database(),
891 brain.brain_dir(),
892 "egui duckdb tauri",
893 &ContextOptions {
894 max_tokens: 900,
895 hop_depth: 0,
896 ..ContextOptions::default()
897 },
898 )
899 .unwrap();
900 let ids: Vec<_> = ctx.nodes.iter().map(|n| n.id.as_str()).collect();
902 let both = ids.contains(&"readme") && ids.iter().any(|i| i.contains("from-readme"));
903 assert!(
904 !both || ctx.nodes.len() == 1,
905 "expected dedup of near-identical hubs; packed={ids:?}"
906 );
907 }
908}