Skip to main content

lean_ctx/core/
graph_context.rs

1//! Graph-driven context loading — automatically includes related files
2//! based on Property Graph proximity and token budgeting.
3//!
4//! Used by `ctx_read` (task mode) to surface a small, budgeted set of
5//! related files (deterministic ordering; no output spam).
6
7use std::collections::{HashMap, HashSet};
8
9use super::graph_provider::{self, GraphProviderSource};
10use super::tokens::count_tokens;
11
12#[derive(Debug)]
13pub struct GraphContext {
14    pub source: GraphProviderSource,
15    pub primary_file: String,
16    pub related_files: Vec<RelatedFile>,
17    pub total_tokens: usize,
18    pub budget_remaining: usize,
19}
20
21#[derive(Debug)]
22pub struct RelatedFile {
23    pub path: String,
24    pub relationship: Relationship,
25    pub token_count: usize,
26}
27
28#[derive(Debug, Clone)]
29pub enum Relationship {
30    DirectDependency,
31    DirectDependent,
32    TransitiveDependency,
33    TypeProvider,
34}
35
36impl Relationship {
37    pub fn label(&self) -> &'static str {
38        match self {
39            Relationship::DirectDependency => "imports",
40            Relationship::DirectDependent => "imported-by",
41            Relationship::TransitiveDependency => "transitive-dep",
42            Relationship::TypeProvider => "type-provider",
43        }
44    }
45
46    fn priority(&self) -> usize {
47        match self {
48            Relationship::DirectDependency => 0,
49            Relationship::TypeProvider => 1,
50            Relationship::DirectDependent => 2,
51            Relationship::TransitiveDependency => 3,
52        }
53    }
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct GraphContextOptions {
58    pub token_budget: usize,
59    pub max_files: usize,
60    pub max_edges: usize,
61    pub max_depth: usize,
62    pub allow_build: bool,
63}
64
65impl Default for GraphContextOptions {
66    fn default() -> Self {
67        Self {
68            token_budget: crate::core::budgets::GRAPH_CONTEXT_TOKEN_BUDGET,
69            max_files: crate::core::budgets::GRAPH_CONTEXT_MAX_FILES,
70            max_edges: crate::core::budgets::GRAPH_CONTEXT_MAX_EDGES,
71            max_depth: crate::core::budgets::GRAPH_CONTEXT_MAX_DEPTH,
72            allow_build: false,
73        }
74    }
75}
76
77pub fn build_graph_context(
78    file_path: &str,
79    project_root: &str,
80    options: Option<GraphContextOptions>,
81) -> Option<GraphContext> {
82    let opts = options.unwrap_or_default();
83
84    let rel_path = file_path
85        .strip_prefix(project_root)
86        .unwrap_or(file_path)
87        .trim_start_matches('/');
88
89    let provider_open = if opts.allow_build {
90        graph_provider::open_or_build(project_root)
91    } else {
92        graph_provider::open_best_effort(project_root)
93    }?;
94
95    let primary_content = std::fs::read_to_string(file_path).ok()?;
96    let primary_tokens = count_tokens(&primary_content);
97
98    let remaining = opts.token_budget.saturating_sub(primary_tokens);
99    if remaining < 200 {
100        return Some(GraphContext {
101            source: provider_open.source,
102            primary_file: rel_path.to_string(),
103            related_files: Vec::new(),
104            total_tokens: primary_tokens,
105            budget_remaining: 0,
106        });
107    }
108
109    let mut candidates = collect_candidates(&provider_open, rel_path, opts.max_depth);
110    candidates.sort_by(|a, b| {
111        a.relationship
112            .priority()
113            .cmp(&b.relationship.priority())
114            .then_with(|| a.path.cmp(&b.path))
115    });
116    if candidates.len() > opts.max_edges {
117        candidates.truncate(opts.max_edges);
118    }
119
120    let mut related: Vec<RelatedFile> = Vec::new();
121    let mut tokens_used = primary_tokens;
122    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
123    seen.insert(rel_path.to_string());
124
125    for candidate in candidates {
126        if related.len() >= opts.max_files {
127            break;
128        }
129        if seen.contains(&candidate.path) {
130            continue;
131        }
132
133        let abs_path = format!("{project_root}/{}", candidate.path);
134        if let Ok(content) = std::fs::read_to_string(&abs_path) {
135            let tokens = count_tokens(&content);
136            if tokens_used + tokens > opts.token_budget {
137                continue;
138            }
139            tokens_used += tokens;
140            seen.insert(candidate.path.clone());
141            related.push(RelatedFile {
142                path: candidate.path,
143                relationship: candidate.relationship,
144                token_count: tokens,
145            });
146        }
147    }
148
149    Some(GraphContext {
150        source: provider_open.source,
151        primary_file: rel_path.to_string(),
152        related_files: related,
153        total_tokens: tokens_used,
154        budget_remaining: opts.token_budget.saturating_sub(tokens_used),
155    })
156}
157
158struct Candidate {
159    path: String,
160    relationship: Relationship,
161}
162
163fn classify_dep(file: &str) -> Relationship {
164    if file.ends_with(".d.ts") {
165        Relationship::TypeProvider
166    } else {
167        Relationship::DirectDependency
168    }
169}
170
171fn collect_candidates(
172    open: &graph_provider::OpenGraphProvider,
173    file_path: &str,
174    max_depth: usize,
175) -> Vec<Candidate> {
176    let mut candidates: Vec<Candidate> = Vec::new();
177
178    for dep in open.provider.dependencies(file_path) {
179        let rel = classify_dep(&dep);
180        candidates.push(Candidate {
181            path: dep,
182            relationship: rel,
183        });
184    }
185
186    for dep in open.provider.dependents(file_path) {
187        candidates.push(Candidate {
188            path: dep,
189            relationship: Relationship::DirectDependent,
190        });
191    }
192
193    for affected in open.provider.related(file_path, max_depth.max(1)) {
194        let already = candidates.iter().any(|c| c.path == affected);
195        if !already {
196            candidates.push(Candidate {
197                path: affected,
198                relationship: Relationship::TransitiveDependency,
199            });
200        }
201    }
202
203    candidates
204}
205
206fn related_files_scored_for_path(
207    file_path: &str,
208    project_root: &str,
209    limit: usize,
210) -> Option<Vec<(String, f64)>> {
211    let provider = graph_provider::open_best_effort(project_root)?;
212    let rel_path = file_path
213        .strip_prefix(project_root)
214        .unwrap_or(file_path)
215        .trim_start_matches('/');
216    let scored = provider.provider.related_files_scored(rel_path, limit);
217    if scored.is_empty() {
218        return None;
219    }
220    Some(scored)
221}
222
223/// Comma-separated repo-relative paths for dependency-cluster hints (e.g. CCP XML).
224pub fn build_related_paths_csv(
225    file_path: &str,
226    project_root: &str,
227    limit: usize,
228) -> Option<String> {
229    let scored = related_files_scored_for_path(file_path, project_root, limit)?;
230    Some(
231        scored
232            .into_iter()
233            .map(|(path, _)| path)
234            .collect::<Vec<_>>()
235            .join(","),
236    )
237}
238
239/// Lightweight one-line hint of the top related files from the Property Graph.
240/// Returns `None` if no graph is available or no neighbors found.
241pub fn build_related_hint(file_path: &str, project_root: &str, limit: usize) -> Option<String> {
242    let scored = related_files_scored_for_path(file_path, project_root, limit)?;
243
244    let entries: Vec<String> = scored
245        .iter()
246        .map(|(path, score)| {
247            let short = path.rsplit('/').next().unwrap_or(path);
248            if *score >= 0.9 {
249                short.to_string()
250            } else {
251                format!("{short} ({:.0}%)", score * 100.0)
252            }
253        })
254        .collect();
255
256    Some(format!("[related: {}]", entries.join(", ")))
257}
258
259/// Repo-relative paths (same style as BM25 chunks / property graph) ranked for RRF graph proximity.
260///
261/// `recent_repo_paths` should be ordered **most recently touched first**. Neighbors from earlier
262/// seeds are ranked before those from later seeds; within a seed, graph `related_files_scored` order is kept.
263pub fn graph_neighbor_ranks_for_recent_files(
264    project_root: &str,
265    recent_repo_paths: &[String],
266    per_seed_limit: usize,
267    max_ranked: usize,
268) -> Option<HashMap<String, usize>> {
269    // Two neighbour sources, blended per seed:
270    //   1. Traversal (co-access) — files the agent actually opened *together*
271    //      (behavioural, the strongest "what next" signal). Listed first so it
272    //      gets the best RRF rank. Works even without a static graph (#289).
273    //   2. Static graph — import/call/type proximity.
274    let coaccess = if crate::core::cooccurrence::traversal_enabled() {
275        Some(crate::core::cooccurrence::load(project_root))
276    } else {
277        None
278    };
279    let open = graph_provider::open_best_effort(project_root);
280    if open.is_none() && coaccess.is_none() {
281        return None;
282    }
283
284    let mut seen = HashSet::<String>::new();
285    let mut ranked: Vec<String> = Vec::new();
286
287    'seeds: for seed in recent_repo_paths {
288        let rel_path = normalize_repo_rel_path(seed, project_root);
289        if rel_path.is_empty() {
290            continue;
291        }
292
293        if let Some(co) = &coaccess {
294            for (path, _) in co.related(&rel_path, per_seed_limit) {
295                if seen.insert(path.clone()) {
296                    ranked.push(path);
297                    if ranked.len() >= max_ranked {
298                        break 'seeds;
299                    }
300                }
301            }
302        }
303
304        if let Some(open) = &open {
305            for (path, _) in open
306                .provider
307                .related_files_scored(&rel_path, per_seed_limit)
308            {
309                if seen.insert(path.clone()) {
310                    ranked.push(path);
311                    if ranked.len() >= max_ranked {
312                        break 'seeds;
313                    }
314                }
315            }
316        }
317    }
318
319    if ranked.is_empty() {
320        None
321    } else {
322        Some(
323            ranked
324                .into_iter()
325                .enumerate()
326                .map(|(i, p)| (p, i))
327                .collect(),
328        )
329    }
330}
331
332fn normalize_repo_rel_path(path: &str, project_root: &str) -> String {
333    let p = path.replace('\\', "/");
334    let root = project_root.trim_end_matches('/').replace('\\', "/");
335    let prefix = format!("{root}/");
336    if let Some(rest) = p.strip_prefix(&prefix) {
337        return rest.to_string();
338    }
339    p.trim_start_matches('/').to_string()
340}
341
342pub fn format_graph_context(ctx: &GraphContext) -> String {
343    if ctx.related_files.is_empty() {
344        return String::new();
345    }
346
347    let source = match ctx.source {
348        GraphProviderSource::PropertyGraph => "property_graph",
349        GraphProviderSource::GraphIndex => "graph_index",
350    };
351    let mut result = format!(
352        "\n--- GRAPH CONTEXT (source={source}, {} related files, {} tok) ---\n",
353        ctx.related_files.len(),
354        ctx.total_tokens
355    );
356
357    for rf in &ctx.related_files {
358        result.push_str(&format!(
359            "  {} [{}] ({} tok)\n",
360            rf.path,
361            rf.relationship.label(),
362            rf.token_count
363        ));
364    }
365
366    result.push_str("--- END GRAPH CONTEXT ---");
367    result
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    #[test]
375    fn relationship_priorities() {
376        assert!(
377            Relationship::DirectDependency.priority() < Relationship::DirectDependent.priority()
378        );
379        assert!(
380            Relationship::DirectDependent.priority()
381                < Relationship::TransitiveDependency.priority()
382        );
383    }
384
385    #[test]
386    fn relationship_labels() {
387        assert_eq!(Relationship::DirectDependency.label(), "imports");
388        assert_eq!(Relationship::DirectDependent.label(), "imported-by");
389        assert_eq!(Relationship::TransitiveDependency.label(), "transitive-dep");
390        assert_eq!(Relationship::TypeProvider.label(), "type-provider");
391    }
392
393    #[test]
394    fn format_empty_context() {
395        let ctx = GraphContext {
396            source: GraphProviderSource::GraphIndex,
397            primary_file: "main.rs".to_string(),
398            related_files: vec![],
399            total_tokens: 100,
400            budget_remaining: 7900,
401        };
402        assert!(format_graph_context(&ctx).is_empty());
403    }
404
405    #[test]
406    fn format_with_related() {
407        let ctx = GraphContext {
408            source: GraphProviderSource::GraphIndex,
409            primary_file: "main.rs".to_string(),
410            related_files: vec![
411                RelatedFile {
412                    path: "lib.rs".to_string(),
413                    relationship: Relationship::DirectDependency,
414                    token_count: 500,
415                },
416                RelatedFile {
417                    path: "utils.rs".to_string(),
418                    relationship: Relationship::DirectDependent,
419                    token_count: 300,
420                },
421            ],
422            total_tokens: 900,
423            budget_remaining: 7100,
424        };
425        let output = format_graph_context(&ctx);
426        assert!(output.contains("2 related files"));
427        assert!(output.contains("lib.rs [imports]"));
428        assert!(output.contains("utils.rs [imported-by]"));
429    }
430
431    #[test]
432    fn nonexistent_root_returns_none() {
433        let result = build_graph_context("/nonexistent/file.rs", "/nonexistent", None);
434        assert!(result.is_none());
435    }
436}