1use 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
223pub 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
239pub 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
259pub 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}