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    let open = graph_provider::open_best_effort(project_root)?;
270    let mut seen = HashSet::<String>::new();
271    let mut ranked: Vec<String> = Vec::new();
272
273    for seed in recent_repo_paths {
274        let rel_path = normalize_repo_rel_path(seed, project_root);
275        if rel_path.is_empty() {
276            continue;
277        }
278        let scored = open
279            .provider
280            .related_files_scored(&rel_path, per_seed_limit);
281        for (path, _) in scored {
282            if seen.insert(path.clone()) {
283                ranked.push(path);
284                if ranked.len() >= max_ranked {
285                    return Some(
286                        ranked
287                            .into_iter()
288                            .enumerate()
289                            .map(|(i, p)| (p, i))
290                            .collect(),
291                    );
292                }
293            }
294        }
295    }
296
297    if ranked.is_empty() {
298        None
299    } else {
300        Some(
301            ranked
302                .into_iter()
303                .enumerate()
304                .map(|(i, p)| (p, i))
305                .collect(),
306        )
307    }
308}
309
310fn normalize_repo_rel_path(path: &str, project_root: &str) -> String {
311    let p = path.replace('\\', "/");
312    let root = project_root.trim_end_matches('/').replace('\\', "/");
313    let prefix = format!("{root}/");
314    if let Some(rest) = p.strip_prefix(&prefix) {
315        return rest.to_string();
316    }
317    p.trim_start_matches('/').to_string()
318}
319
320pub fn format_graph_context(ctx: &GraphContext) -> String {
321    if ctx.related_files.is_empty() {
322        return String::new();
323    }
324
325    let source = match ctx.source {
326        GraphProviderSource::PropertyGraph => "property_graph",
327        GraphProviderSource::GraphIndex => "graph_index",
328    };
329    let mut result = format!(
330        "\n--- GRAPH CONTEXT (source={source}, {} related files, {} tok) ---\n",
331        ctx.related_files.len(),
332        ctx.total_tokens
333    );
334
335    for rf in &ctx.related_files {
336        result.push_str(&format!(
337            "  {} [{}] ({} tok)\n",
338            rf.path,
339            rf.relationship.label(),
340            rf.token_count
341        ));
342    }
343
344    result.push_str("--- END GRAPH CONTEXT ---");
345    result
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn relationship_priorities() {
354        assert!(
355            Relationship::DirectDependency.priority() < Relationship::DirectDependent.priority()
356        );
357        assert!(
358            Relationship::DirectDependent.priority()
359                < Relationship::TransitiveDependency.priority()
360        );
361    }
362
363    #[test]
364    fn relationship_labels() {
365        assert_eq!(Relationship::DirectDependency.label(), "imports");
366        assert_eq!(Relationship::DirectDependent.label(), "imported-by");
367        assert_eq!(Relationship::TransitiveDependency.label(), "transitive-dep");
368        assert_eq!(Relationship::TypeProvider.label(), "type-provider");
369    }
370
371    #[test]
372    fn format_empty_context() {
373        let ctx = GraphContext {
374            source: GraphProviderSource::GraphIndex,
375            primary_file: "main.rs".to_string(),
376            related_files: vec![],
377            total_tokens: 100,
378            budget_remaining: 7900,
379        };
380        assert!(format_graph_context(&ctx).is_empty());
381    }
382
383    #[test]
384    fn format_with_related() {
385        let ctx = GraphContext {
386            source: GraphProviderSource::GraphIndex,
387            primary_file: "main.rs".to_string(),
388            related_files: vec![
389                RelatedFile {
390                    path: "lib.rs".to_string(),
391                    relationship: Relationship::DirectDependency,
392                    token_count: 500,
393                },
394                RelatedFile {
395                    path: "utils.rs".to_string(),
396                    relationship: Relationship::DirectDependent,
397                    token_count: 300,
398                },
399            ],
400            total_tokens: 900,
401            budget_remaining: 7100,
402        };
403        let output = format_graph_context(&ctx);
404        assert!(output.contains("2 related files"));
405        assert!(output.contains("lib.rs [imports]"));
406        assert!(output.contains("utils.rs [imported-by]"));
407    }
408
409    #[test]
410    fn nonexistent_root_returns_none() {
411        let result = build_graph_context("/nonexistent/file.rs", "/nonexistent", None);
412        assert!(result.is_none());
413    }
414}