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 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}