Skip to main content

rustbrain_core/
context.rs

1//! Graph-aware AI context assembly with token budgeting.
2//!
3//! Pipeline used by [`crate::Brain::context_for_prompt`]:
4//!
5//! 1. Ranked FTS seeds ([`crate::query`]) with stopword-aware query rewrite
6//! 2. Optional CSR k-hop expansion from `.brain/graph.mmap` (doc seeds preferred)
7//! 3. Score fusion (seed score × edge weight × hop decay) + symbol quality filters
8//! 4. Pack nodes with body excerpts until the approximate character/token budget is exhausted
9//!
10//! Token accounting is intentionally simple (`chars / 4`). It is good enough for
11//! agent prompt packing, not for billing-grade tokenizer parity.
12
13use crate::error::Result;
14use crate::fts::{is_generic_topic, prepare_search_query, tokenize_query};
15use crate::hubs::{is_planning_intent, is_release_intent, HUB_BACKLOG, HUB_CHANGELOG, HUB_README, HUB_ROADMAP};
16use crate::query::{QueryOptions, RankedHit};
17use crate::storage::Database;
18use crate::types::{ContextBundle, ContextNode, ContextRole, Node, NodeType};
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21use std::path::Path;
22use std::time::Instant;
23
24/// Max characters of body excerpt packed per note/seed.
25const MAX_EXCERPT_CHARS: usize = 900;
26/// Max characters for symbol neighbor excerpts (usually doc comments).
27const MAX_SYMBOL_EXCERPT_CHARS: usize = 280;
28/// Soft cap on packed symbol neighbors even when hop_to_symbols is on.
29const MAX_PACKED_SYMBOL_NEIGHBORS: usize = 8;
30
31/// Options controlling [`assemble_context`] and [`crate::Brain::context_for_prompt_with`].
32#[derive(Debug, Clone)]
33pub struct ContextOptions {
34    /// Approximate max tokens for the packed bundle (`chars ≈ tokens × 4`).
35    pub max_tokens: usize,
36    /// Graph expansion depth (`0` = seeds only; `1` is the usual default).
37    pub hop_depth: usize,
38    /// Max seed nodes taken from the ranked query before expansion.
39    pub max_seeds: usize,
40    /// Max total nodes packed into the bundle (seeds + neighbors).
41    pub max_nodes: usize,
42    /// Multiplicative decay applied per hop for neighbor scores.
43    pub hop_decay: f32,
44    /// Exclude pure code symbols from seeds (and from neighbors unless [`Self::hop_to_symbols`]).
45    pub no_symbols: bool,
46    /// Only include these node types when non-empty.
47    pub include_types: Vec<crate::types::NodeType>,
48    /// Always exclude these node types.
49    pub exclude_types: Vec<crate::types::NodeType>,
50    /// When true, allow graph hops *to* symbols even if `no_symbols` filters seeds.
51    /// Default true so ADR → `symbol:foo` remains useful for agents.
52    pub hop_to_symbols: bool,
53    /// Prefer expanding the graph from non-symbol seeds (docs/README). Default true.
54    pub hop_from_docs_only: bool,
55    /// Include body excerpts from FTS content (or summary fallback). Default true.
56    pub include_excerpts: bool,
57    /// Optional SubBrain scope filter for seeds (neighbors may still hop out).
58    pub scope: Option<String>,
59    /// MainBrain id when scoping (default `main`).
60    pub main_scope: String,
61    /// How much MainBrain to mix into scoped seeds (default hubs-only).
62    pub scope_main: crate::query::ScopeMainInclude,
63}
64
65impl Default for ContextOptions {
66    fn default() -> Self {
67        Self {
68            max_tokens: 2048,
69            hop_depth: 1,
70            max_seeds: 8,
71            max_nodes: 24,
72            hop_decay: 0.65,
73            // Agent-friendly: notes first; symbols arrive via hops when useful.
74            no_symbols: true,
75            include_types: Vec::new(),
76            exclude_types: Vec::new(),
77            hop_to_symbols: true,
78            hop_from_docs_only: true,
79            include_excerpts: true,
80            scope: None,
81            main_scope: crate::scopes::MAIN_SCOPE.to_string(),
82            scope_main: crate::query::ScopeMainInclude::HubsOnly,
83        }
84    }
85}
86
87impl ContextOptions {
88    /// Agent-oriented defaults (same as [`Default`] as of 0.3.0).
89    pub fn agent() -> Self {
90        Self::default()
91    }
92
93    /// Include symbols as seeds and neighbors (power-user / debugging).
94    pub fn with_symbols(mut self) -> Self {
95        self.no_symbols = false;
96        self
97    }
98
99    fn seed_query_opts(&self) -> QueryOptions {
100        QueryOptions {
101            limit: self.max_seeds.saturating_mul(2).max(8),
102            no_symbols: self.no_symbols,
103            include_types: self.include_types.clone(),
104            exclude_types: self.exclude_types.clone(),
105            scope: self.scope.clone(),
106            main_scope: self.main_scope.clone(),
107            scope_main: self.scope_main,
108            ..QueryOptions::default()
109        }
110    }
111
112    fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
113        use crate::types::NodeType;
114        if !self.include_types.is_empty() && !self.include_types.contains(ty) {
115            // Allow symbol neighbors when hop_to_symbols and role is Neighbor
116            if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
117                return false;
118            }
119        }
120        if self.exclude_types.contains(ty) {
121            return false;
122        }
123        if *ty == NodeType::Symbol {
124            if role == ContextRole::Seed && self.no_symbols {
125                return false;
126            }
127            if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
128                return false;
129            }
130        }
131        true
132    }
133}
134
135/// Intermediate scored candidate before token packing.
136#[derive(Debug, Clone)]
137struct Candidate {
138    node: Node,
139    score: f32,
140    role: ContextRole,
141    hop: u8,
142    excerpt: Option<String>,
143}
144
145/// Assemble a graph-aware context bundle for an agent prompt.
146///
147/// `brain_dir` is the `.brain` directory (for loading `graph.mmap`). When the
148/// mmap file is missing or the `mmap` feature is disabled, expansion is skipped
149/// and only ranked seeds are packed.
150///
151/// # Errors
152///
153/// Propagates FTS / database errors from the seed query.
154pub fn assemble_context(
155    db: &Database,
156    brain_dir: &Path,
157    prompt: &str,
158    opts: &ContextOptions,
159) -> Result<ContextBundle> {
160    let start = Instant::now();
161    let char_budget = opts.max_tokens.saturating_mul(4).max(256);
162    let query_tokens = prepare_search_query(prompt)
163        .map(|p| p.tokens)
164        .unwrap_or_else(|_| tokenize_query(prompt));
165
166    let qopts = opts.seed_query_opts();
167    let mut seeds = db.search_ranked(prompt, &qopts)?;
168    let generic = is_generic_topic(&query_tokens);
169    let release_intent = is_release_intent(&query_tokens);
170    let planning_intent = is_planning_intent(&query_tokens);
171    let cold_start = seeds.is_empty() || generic;
172    // Soft / empty retrieval → inject project hubs (README, CHANGELOG, …).
173    if cold_start || release_intent || planning_intent {
174        inject_hub_seeds(
175            db,
176            &mut seeds,
177            opts,
178            release_intent,
179            planning_intent,
180            cold_start,
181        )?;
182    }
183
184    let mut candidates: Vec<Candidate> = Vec::new();
185    let mut seed_ids: HashSet<String> = HashSet::new();
186
187    for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
188        if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
189            continue;
190        }
191        // Skip pure ADR templates — they burn budget without knowledge.
192        if is_template_stub(&hit.node) {
193            continue;
194        }
195        seed_ids.insert(hit.node.id.clone());
196        // Slight rank-position prior
197        let mut score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
198        // Prefer hand-written / ADR over generated harvest when scores are close.
199        if is_generated_path(&hit.node) {
200            score *= 0.85;
201        }
202        if hit.node.node_type == NodeType::Adr {
203            score *= 1.08;
204        }
205        let excerpt = if opts.include_excerpts {
206            load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
207        } else {
208            None
209        };
210        candidates.push(Candidate {
211            node: hit.node,
212            score,
213            role: ContextRole::Seed,
214            hop: 0,
215            excerpt,
216        });
217    }
218
219    // If filters wiped everything (e.g. only template matched), hub-inject again.
220    if candidates.is_empty() {
221        let mut hub_hits = Vec::new();
222        inject_hub_seeds(db, &mut hub_hits, opts, true, true, true)?;
223        for hit in hub_hits.into_iter().take(6) {
224            if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
225                continue;
226            }
227            if seed_ids.contains(&hit.node.id) {
228                continue;
229            }
230            seed_ids.insert(hit.node.id.clone());
231            let excerpt = if opts.include_excerpts {
232                load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
233            } else {
234                None
235            };
236            candidates.push(Candidate {
237                node: hit.node,
238                score: hit.score,
239                role: ContextRole::Seed,
240                hop: 0,
241                excerpt,
242            });
243        }
244    }
245
246    let mut graph_nodes = 0usize;
247    let mut graph_edges = 0usize;
248    let mut neighbor_ids: Vec<String> = Vec::new();
249
250    #[cfg(feature = "mmap")]
251    if opts.hop_depth > 0 {
252        let mmap_path = brain_dir.join("graph.mmap");
253        if mmap_path.exists() {
254            if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
255                graph_nodes = graph.node_count;
256                graph_edges = graph.edge_count;
257
258                let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
259
260                for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
261                    if opts.hop_from_docs_only && cand.node.node_type == NodeType::Symbol {
262                        continue;
263                    }
264                    if let Some(idx) = graph.index_of(&cand.node.id) {
265                        for (nidx, edge_w) in
266                            graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
267                        {
268                            if let Some(id) = graph.node_id(nidx as usize) {
269                                if seed_ids.contains(id) {
270                                    continue;
271                                }
272                                let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
273                                    1
274                                } else {
275                                    2
276                                };
277                                let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
278                                best_neighbor
279                                    .entry(id.to_string())
280                                    .and_modify(|(s, h)| {
281                                        if nscore > *s {
282                                            *s = nscore;
283                                            *h = hop;
284                                        }
285                                    })
286                                    .or_insert((nscore, hop));
287                            }
288                        }
289                    }
290                }
291
292                let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
293                neigh_sorted.sort_by(|a, b| {
294                    b.1 .0
295                        .partial_cmp(&a.1 .0)
296                        .unwrap_or(std::cmp::Ordering::Equal)
297                });
298
299                for (id, (score, hop)) in neigh_sorted {
300                    neighbor_ids.push(id.clone());
301                    if let Some(node) = db.get_node(&id)? {
302                        if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
303                            continue;
304                        }
305                        let mut nscore = score;
306                        if node.node_type == NodeType::Symbol {
307                            match symbol_neighbor_quality(&node, &query_tokens) {
308                                SymbolQuality::Drop => continue,
309                                SymbolQuality::Keep { boost } => nscore *= boost,
310                            }
311                        }
312                        let max_ex = if node.node_type == NodeType::Symbol {
313                            MAX_SYMBOL_EXCERPT_CHARS
314                        } else {
315                            MAX_EXCERPT_CHARS
316                        };
317                        let excerpt = if opts.include_excerpts {
318                            load_excerpt(db, &node, max_ex)
319                        } else {
320                            None
321                        };
322                        candidates.push(Candidate {
323                            node,
324                            score: nscore,
325                            role: ContextRole::Neighbor,
326                            hop,
327                            excerpt,
328                        });
329                    }
330                }
331            }
332        }
333    }
334
335    // Sort for agent-useful packs: seeds before neighbors, decisions before symbols,
336    // then raw score. Keeps ADR + README above opportunistic symbol hops.
337    candidates.sort_by(|a, b| {
338        pack_rank(b)
339            .partial_cmp(&pack_rank(a))
340            .unwrap_or(std::cmp::Ordering::Equal)
341    });
342
343    let mut packed: Vec<ContextNode> = Vec::new();
344    let mut used_chars = estimate_overhead(prompt);
345    let mut seen = HashSet::new();
346    let mut symbol_neighbors = 0usize;
347    let mut packed_excerpts: Vec<String> = Vec::new();
348
349    for cand in candidates {
350        if packed.len() >= opts.max_nodes {
351            break;
352        }
353        if !seen.insert(cand.node.id.clone()) {
354            continue;
355        }
356        if cand.role == ContextRole::Neighbor
357            && cand.node.node_type == NodeType::Symbol
358            && symbol_neighbors >= MAX_PACKED_SYMBOL_NEIGHBORS
359        {
360            continue;
361        }
362        // Prefer a single README-family hub (root readme XOR from-readme harvest).
363        if is_readme_family(&cand.node.id)
364            && packed.iter().any(|n| is_readme_family(&n.id))
365        {
366            continue;
367        }
368        // Don't double-pack near-identical changelog paths if aliases collide.
369        if cand.node.id == HUB_CHANGELOG
370            && packed.iter().any(|n| n.id == HUB_CHANGELOG)
371        {
372            continue;
373        }
374        // Drop near-duplicate body text (harvested clones of the same prose).
375        if let Some(ex) = &cand.excerpt {
376            if packed_excerpts
377                .iter()
378                .any(|prev| excerpt_jaccard(prev, ex) > 0.55)
379            {
380                continue;
381            }
382        }
383        let ctx_node = to_context_node(&cand);
384        let cost = estimate_node_chars(&ctx_node);
385        if used_chars + cost > char_budget && !packed.is_empty() {
386            // Always try to keep at least one seed
387            if cand.role == ContextRole::Neighbor {
388                continue;
389            }
390            if packed.iter().any(|n| n.role == ContextRole::Seed) {
391                continue;
392            }
393        }
394        if used_chars + cost > char_budget && !packed.is_empty() {
395            break;
396        }
397        used_chars += cost;
398        if cand.role == ContextRole::Neighbor && cand.node.node_type == NodeType::Symbol {
399            symbol_neighbors += 1;
400        }
401        if let Some(ex) = &ctx_node.excerpt {
402            packed_excerpts.push(ex.clone());
403        }
404        packed.push(ctx_node);
405    }
406
407    neighbor_ids.retain(|id| !seed_ids.contains(id));
408    neighbor_ids.truncate(48);
409
410    let latency_us = start.elapsed().as_micros() as u64;
411    let tokens_used = used_chars.div_ceil(4);
412
413    Ok(ContextBundle {
414        prompt: prompt.to_string(),
415        max_tokens: opts.max_tokens,
416        tokens_used,
417        nodes: packed,
418        neighbor_ids,
419        latency_us,
420        graph_nodes,
421        graph_edges,
422    })
423}
424
425enum SymbolQuality {
426    Drop,
427    Keep { boost: f32 },
428}
429
430/// Drop theme consts / short noise; boost query-matching or method-like symbols.
431fn symbol_neighbor_quality(node: &Node, query_tokens: &[String]) -> SymbolQuality {
432    let title = node.title.to_lowercase();
433    let id = node.id.to_lowercase();
434    let leaf = id.rsplit('/').next().unwrap_or(&id);
435
436    // Query term appears in symbol name → keep and boost.
437    for t in query_tokens {
438        if title.contains(t.as_str()) || id.contains(t.as_str()) {
439            return SymbolQuality::Keep { boost: 1.8 };
440        }
441    }
442
443    // Method / path-like symbols (Type::method or multi-segment).
444    if title.contains("::") || leaf.contains("::") {
445        return SymbolQuality::Keep { boost: 1.15 };
446    }
447
448    // Very short or SCREAMING_SNAKE theme constants without query match.
449    let name = title.split("::").last().unwrap_or(&title);
450    if name.len() <= 2 {
451        return SymbolQuality::Drop;
452    }
453    if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() <= 12 {
454        // OK, ERR, BG_*, ACCENT_* style noise
455        return SymbolQuality::Drop;
456    }
457    // Generic single-token modules without query match (derive, tree, …)
458    if !name.contains('_') && !title.contains("::") && name.len() <= 8 {
459        // Keep types that look like CamelCase API entry points.
460        let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
461        let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
462        if has_upper && has_lower {
463            return SymbolQuality::Keep { boost: 1.0 };
464        }
465        return SymbolQuality::Drop;
466    }
467
468    SymbolQuality::Keep { boost: 1.0 }
469}
470
471/// Soft-inject project hubs when FTS is empty, generic, or intent-matched.
472fn inject_hub_seeds(
473    db: &Database,
474    seeds: &mut Vec<RankedHit>,
475    opts: &ContextOptions,
476    release_intent: bool,
477    planning_intent: bool,
478    cold_start: bool,
479) -> Result<()> {
480    let existing: HashSet<String> = seeds.iter().map(|h| h.node.id.clone()).collect();
481    let mut hubs: Vec<(&str, f32)> = Vec::new();
482    if cold_start {
483        hubs.extend([
484            (HUB_README, 4.5_f32),
485            ("docs/goals/from-readme", 3.8),
486            ("docs/implementation/module-map.generated", 2.2),
487            ("docs/goals/readme", 1.5),
488        ]);
489    }
490    if release_intent || cold_start {
491        // Keep a Changelog is ground truth for "what shipped".
492        hubs.push((HUB_CHANGELOG, if release_intent { 5.0 } else { 3.2 }));
493    }
494    if planning_intent || cold_start {
495        hubs.push((HUB_ROADMAP, if planning_intent { 4.6 } else { 2.4 }));
496        hubs.push((HUB_BACKLOG, if planning_intent { 4.4 } else { 2.2 }));
497    }
498    let mut seen = HashSet::new();
499    hubs.retain(|(id, _)| seen.insert(*id));
500
501    for (id, score) in hubs {
502        if existing.contains(id) {
503            continue;
504        }
505        if let Some(node) = db.get_node(id)? {
506            if !opts.allows_pack(&node.node_type, ContextRole::Seed) {
507                continue;
508            }
509            if is_template_stub(&node) {
510                continue;
511            }
512            seeds.push(RankedHit {
513                node,
514                score,
515                reasons: vec![format!("hub-fallback:{id}")],
516            });
517        }
518    }
519    // Keep highest first for take(max_seeds).
520    seeds.sort_by(|a, b| {
521        b.score
522            .partial_cmp(&a.score)
523            .unwrap_or(std::cmp::Ordering::Equal)
524    });
525    Ok(())
526}
527
528fn is_template_stub(node: &Node) -> bool {
529    let id = node.id.to_lowercase();
530    let path = node.file_path.as_deref().unwrap_or("").to_lowercase();
531    let title = node.title.to_lowercase();
532    id.contains("template")
533        || path.ends_with("template.md")
534        || title.contains("template")
535        || title == "adr template"
536}
537
538fn is_generated_path(node: &Node) -> bool {
539    let path = node.file_path.as_deref().unwrap_or("");
540    path.contains("from-readme")
541        || path.contains(".generated.")
542        || path.ends_with("module-map.generated.md")
543}
544
545fn is_readme_family(id: &str) -> bool {
546    id == "readme" || id.contains("from-readme")
547}
548
549fn excerpt_jaccard(a: &str, b: &str) -> f32 {
550    let ta: HashSet<&str> = a
551        .split_whitespace()
552        .filter(|w| w.len() > 2)
553        .collect();
554    let tb: HashSet<&str> = b
555        .split_whitespace()
556        .filter(|w| w.len() > 2)
557        .collect();
558    if ta.is_empty() || tb.is_empty() {
559        return 0.0;
560    }
561    let inter = ta.intersection(&tb).count() as f32;
562    let uni = ta.union(&tb).count() as f32;
563    if uni == 0.0 {
564        0.0
565    } else {
566        inter / uni
567    }
568}
569
570fn pack_rank(c: &Candidate) -> f32 {
571    let role = match c.role {
572        ContextRole::Seed => 3.0,
573        ContextRole::Neighbor => 0.0,
574    };
575    let ty = match c.node.node_type {
576        NodeType::Adr => 2.5,
577        NodeType::Goal => 2.0,
578        NodeType::Changelog => 1.95,
579        NodeType::Plan => 1.9,
580        NodeType::EdgeCase => 1.8,
581        NodeType::Analysis => 1.65,
582        NodeType::Concept => 1.4,
583        NodeType::Reference => 1.2,
584        NodeType::Alternative => 1.1,
585        NodeType::Symbol => 0.0,
586    };
587    // Score still dominates large gaps; bonuses break near-ties for agent packs.
588    c.score + role + ty
589}
590
591fn load_excerpt(db: &Database, node: &Node, max_chars: usize) -> Option<String> {
592    let raw = db
593        .get_fts_content(&node.id)
594        .ok()
595        .flatten()
596        .filter(|s| !s.trim().is_empty())
597        .or_else(|| node.summary.clone().filter(|s| !s.trim().is_empty()))?;
598    let cleaned = strip_yaml_frontmatter(&raw);
599    Some(truncate_excerpt(cleaned, max_chars))
600}
601
602/// Drop leading `---` YAML frontmatter so agent packs show body prose first.
603fn strip_yaml_frontmatter(s: &str) -> &str {
604    let t = s.trim_start();
605    if !t.starts_with("---") {
606        return s.trim();
607    }
608    let after_open = &t[3..];
609    // Allow optional newline after opening fence.
610    let body = after_open.strip_prefix('\n').unwrap_or(after_open);
611    if let Some(idx) = body.find("\n---") {
612        let rest = &body[idx + 4..];
613        return rest.trim_start_matches(['\r', '\n']).trim();
614    }
615    s.trim()
616}
617
618fn truncate_excerpt(s: &str, max_chars: usize) -> String {
619    let s = s.trim();
620    if s.chars().count() <= max_chars {
621        return s.to_string();
622    }
623    let mut out = String::new();
624    for (i, ch) in s.chars().enumerate() {
625        if i >= max_chars.saturating_sub(1) {
626            break;
627        }
628        out.push(ch);
629    }
630    out.push('…');
631    out
632}
633
634fn to_context_node(c: &Candidate) -> ContextNode {
635    ContextNode {
636        id: c.node.id.clone(),
637        node_type: c.node.node_type.clone(),
638        title: c.node.title.clone(),
639        summary: c.node.summary.clone(),
640        excerpt: c.excerpt.clone(),
641        file_path: c.node.file_path.clone(),
642        score_hint: c.score,
643        role: c.role,
644        hop: c.hop,
645    }
646}
647
648fn estimate_overhead(prompt: &str) -> usize {
649    120 + prompt.len()
650}
651
652fn estimate_node_chars(n: &ContextNode) -> usize {
653    let mut c = n.id.len() + n.title.len() + 48;
654    if let Some(s) = &n.summary {
655        c += s.len();
656    }
657    if let Some(ex) = &n.excerpt {
658        c += ex.len();
659    }
660    if let Some(p) = &n.file_path {
661        c += p.len();
662    }
663    c
664}
665
666/// Convert ranked hits to nodes (helper for CLI / adapters).
667pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
668    hits.into_iter().map(|h| h.node).collect()
669}
670
671/// Multi-workspace ranked hit (workspace path + hit).
672///
673/// Prefer [`crate::GlobalRankedHit`] for the registry API; this type remains for
674/// ad-hoc tooling that reuses the same shape.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct WorkspaceHit {
677    /// Absolute workspace path that produced the hit.
678    pub workspace: String,
679    /// Ranked hit within that workspace.
680    pub hit: RankedHit,
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686    use crate::brain::Brain;
687    use crate::indexer::WorkspaceIndexer;
688    use crate::storage::Database;
689    use tempfile::tempdir;
690
691    #[test]
692    fn context_includes_graph_neighbors_within_budget() {
693        let dir = tempdir().unwrap();
694        let docs = dir.path().join("docs");
695        std::fs::create_dir_all(&docs).unwrap();
696        // Only seed A is FTS-matchable for "alphaunique"; B is only reachable via graph.
697        std::fs::write(
698            docs.join("alpha.md"),
699            "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
700        )
701        .unwrap();
702        std::fs::write(
703            docs.join("beta.md"),
704            "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
705        )
706        .unwrap();
707
708        let mut brain = Brain::create(dir.path()).unwrap();
709        brain.sync().unwrap();
710
711        let opts = ContextOptions {
712            max_tokens: 2048,
713            hop_depth: 1,
714            max_seeds: 4,
715            max_nodes: 12,
716            hop_decay: 0.7,
717            ..ContextOptions::default()
718        };
719        let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
720            .unwrap();
721        assert!(!ctx.nodes.is_empty());
722        assert!(ctx.tokens_used > 0);
723        assert!(ctx.tokens_used <= opts.max_tokens + 64); // soft bound with estimator slack
724
725        // Neighbor should be discovered via graph even if not an FTS seed.
726        let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
727            || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
728        assert!(
729            has_beta,
730            "expected beta neighbor via graph; nodes={:?} neigh={:?}",
731            ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
732            ctx.neighbor_ids
733        );
734    }
735
736    #[test]
737    fn tight_budget_limits_nodes() {
738        let dir = tempdir().unwrap();
739        let docs = dir.path().join("docs");
740        std::fs::create_dir_all(&docs).unwrap();
741        for i in 0..8 {
742            std::fs::write(
743                docs.join(format!("n{i}.md")),
744                format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
745            )
746            .unwrap();
747        }
748        let mut brain = Brain::create(dir.path()).unwrap();
749        brain.sync().unwrap();
750        let opts = ContextOptions {
751            max_tokens: 40, // very tight
752            hop_depth: 0,
753            max_seeds: 8,
754            max_nodes: 20,
755            hop_decay: 0.5,
756            ..ContextOptions::default()
757        };
758        let ctx =
759            assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
760        assert!(!ctx.nodes.is_empty());
761        assert!(ctx.nodes.len() < 8, "budget should clip nodes");
762    }
763
764    #[test]
765    fn natural_language_prompt_seeds_and_packs_excerpt() {
766        let dir = tempdir().unwrap();
767        std::fs::write(
768            dir.path().join("README.md"),
769            "# demotool\n\nLightweight **egui** explorer powered by DuckDB.\n\
770             Inspired by Duckling but avoids Tauri/WebView.\n",
771        )
772        .unwrap();
773        let mut brain = Brain::create(dir.path()).unwrap();
774        brain.sync().unwrap();
775
776        let ctx = assemble_context(
777            brain.database(),
778            brain.brain_dir(),
779            "why egui not tauri",
780            &ContextOptions::default(),
781        )
782        .unwrap();
783        assert!(
784            !ctx.nodes.is_empty(),
785            "expected seeds for NL prompt; packed=0"
786        );
787        let has_excerpt = ctx.nodes.iter().any(|n| {
788            n.excerpt
789                .as_ref()
790                .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("tauri"))
791                .unwrap_or(false)
792        });
793        assert!(
794            has_excerpt,
795            "expected body excerpt mentioning egui/tauri; nodes={:?}",
796            ctx.nodes
797                .iter()
798                .map(|n| (&n.id, &n.excerpt))
799                .collect::<Vec<_>>()
800        );
801    }
802
803    #[test]
804    fn release_prompt_prefers_changelog_hub() {
805        let dir = tempdir().unwrap();
806        std::fs::write(
807            dir.path().join("README.md"),
808            "# Demo\n\nShip notes live in the changelog.\n",
809        )
810        .unwrap();
811        std::fs::write(
812            dir.path().join("CHANGELOG.md"),
813            "# Changelog\n\n## [1.2.3] - 2026-01-01\n\n### Added\n- important feature alpha\n",
814        )
815        .unwrap();
816        let brain = dir.path().join(".brain");
817        std::fs::create_dir_all(&brain).unwrap();
818        let db = Database::open(brain.join("db.sqlite")).unwrap();
819        let indexer = WorkspaceIndexer::new(db, dir.path());
820        indexer.index_workspace().unwrap();
821        let db = Database::open(brain.join("db.sqlite")).unwrap();
822        let ctx = assemble_context(
823            &db,
824            &brain,
825            "what shipped in the changelog release",
826            &ContextOptions {
827                max_tokens: 800,
828                hop_depth: 0,
829                ..ContextOptions::default()
830            },
831        )
832        .unwrap();
833        assert!(
834            ctx.nodes.iter().any(|n| n.id == "changelog"),
835            "expected changelog hub packed; nodes={:?}",
836            ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
837        );
838    }
839
840    #[test]
841    fn generic_overview_prompt_falls_back_to_hub() {
842        let dir = tempdir().unwrap();
843        std::fs::write(
844            dir.path().join("README.md"),
845            "# demotool\n\nNative egui + DuckDB CLI. Avoids Tauri.\n",
846        )
847        .unwrap();
848        let mut brain = Brain::create(dir.path()).unwrap();
849        brain.sync().unwrap();
850
851        for prompt in [
852            "summarize architecture",
853            "what is this project about",
854            "give an overview",
855        ] {
856            let ctx = assemble_context(
857                brain.database(),
858                brain.brain_dir(),
859                prompt,
860                &ContextOptions::default(),
861            )
862            .unwrap();
863            assert!(
864                !ctx.nodes.is_empty(),
865                "expected hub fallback for `{prompt}`; packed=0"
866            );
867            let has_hub = ctx.nodes.iter().any(|n| {
868                n.id == "readme"
869                    || n.id.contains("from-readme")
870                    || n.excerpt
871                        .as_ref()
872                        .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("duckdb"))
873                        .unwrap_or(false)
874            });
875            assert!(
876                has_hub,
877                "expected README hub content for `{prompt}`; nodes={:?}",
878                ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
879            );
880        }
881    }
882
883    #[test]
884    fn strips_frontmatter_from_excerpts() {
885        let dir = tempdir().unwrap();
886        std::fs::create_dir_all(dir.path().join("docs")).unwrap();
887        std::fs::write(
888            dir.path().join("docs/a.md"),
889            "---\ntags: [x]\nnode_type: concept\n---\n# Alpha\n\nBody about egui.\n",
890        )
891        .unwrap();
892        let mut brain = Brain::create(dir.path()).unwrap();
893        brain.sync().unwrap();
894        let ctx = assemble_context(
895            brain.database(),
896            brain.brain_dir(),
897            "egui",
898            &ContextOptions {
899                hop_depth: 0,
900                ..ContextOptions::default()
901            },
902        )
903        .unwrap();
904        let ex = ctx
905            .nodes
906            .iter()
907            .find_map(|n| n.excerpt.as_ref())
908            .expect("excerpt");
909        assert!(
910            !ex.trim_start().starts_with("---"),
911            "frontmatter leaked into excerpt: {ex}"
912        );
913        assert!(ex.to_lowercase().contains("egui") || ex.contains("Alpha"));
914    }
915
916    #[test]
917    fn prefers_adr_seed_over_symbol_neighbor() {
918        let dir = tempdir().unwrap();
919        std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
920        std::fs::create_dir_all(dir.path().join("src")).unwrap();
921        std::fs::write(
922            dir.path().join("Cargo.toml"),
923            "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
924        )
925        .unwrap();
926        std::fs::write(
927            dir.path().join("src/lib.rs"),
928            "/// Run SQL via duckdb CLI.\npub fn run_sql() {}\n",
929        )
930        .unwrap();
931        std::fs::write(
932            dir.path().join("docs/adr/sql.md"),
933            "---\nnode_type: adr\n---\n# SQL via duckdb\n\nUse symbol:run_sql for SQL execution via duckdb CLI.\n",
934        )
935        .unwrap();
936        let mut brain = Brain::create(dir.path()).unwrap();
937        brain.sync().unwrap();
938        let _ = brain.sync().unwrap(); // resolve anchors
939        let ctx = assemble_context(
940            brain.database(),
941            brain.brain_dir(),
942            "how does SQL execution work",
943            &ContextOptions::default(),
944        )
945        .unwrap();
946        assert!(!ctx.nodes.is_empty());
947        let first = &ctx.nodes[0];
948        assert_eq!(
949            first.node_type,
950            NodeType::Adr,
951            "expected ADR first, got {:?} id={}",
952            first.node_type,
953            first.id
954        );
955    }
956
957    #[test]
958    fn dedups_near_identical_excerpts() {
959        let dir = tempdir().unwrap();
960        let body = "Lightweight egui explorer with DuckDB CLI. Avoids Tauri completely.\n".repeat(3);
961        std::fs::write(
962            dir.path().join("README.md"),
963            format!("# demotool\n\n{body}"),
964        )
965        .unwrap();
966        std::fs::create_dir_all(dir.path().join("docs/goals")).unwrap();
967        std::fs::write(
968            dir.path().join("docs/goals/from-readme.md"),
969            format!(
970                "---\nnode_type: goal\n---\n# Goals harvested from README\n\n{body}"
971            ),
972        )
973        .unwrap();
974        let mut brain = Brain::create(dir.path()).unwrap();
975        brain.sync().unwrap();
976        let ctx = assemble_context(
977            brain.database(),
978            brain.brain_dir(),
979            "egui duckdb tauri",
980            &ContextOptions {
981                max_tokens: 900,
982                hop_depth: 0,
983                ..ContextOptions::default()
984            },
985        )
986        .unwrap();
987        // Should not pack both near-identical README + harvest when budget is modest.
988        let ids: Vec<_> = ctx.nodes.iter().map(|n| n.id.as_str()).collect();
989        let both = ids.contains(&"readme") && ids.iter().any(|i| i.contains("from-readme"));
990        assert!(
991            !both || ctx.nodes.len() == 1,
992            "expected dedup of near-identical hubs; packed={ids:?}"
993        );
994    }
995}