1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3use std::time::Instant;
4
5use anyhow::Result;
6use rayon::prelude::*;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use crate::candidate_files;
10use crate::config::budget::BUDGET;
11use crate::config::graph_filtering::GRAPH_FILTERING;
12use crate::config::limits::LIMITS;
13use crate::config::tokenization::TOKENIZATION;
14use crate::core::{compute_seed_weights, identify_core_fragments};
15use crate::discovery::{
16 BM25Discovery, DefaultDiscovery, DiscoveryContext, DiscoveryStrategy, EnsembleDiscovery,
17 TestFileDiscovery,
18};
19use crate::fragmentation::process_files_for_fragments;
20use crate::git::{self, CatFileBatch};
21use crate::mode::{DiscoveryKind, PipelineConfig, ScoringKind, ScoringMode};
22use crate::postpass;
23use crate::render::{self, DiffContextOutput};
24use crate::scoring::{BM25Scoring, EgoGraphScoring, PPRScoring, ScoringResult, ScoringStrategy};
25use crate::signatures::generate_signature_variants;
26use crate::tokenizer::count_tokens;
27use crate::types::{Fragment, FragmentId};
28use crate::utility::InformationNeed;
29
30pub struct ScoredState {
37 pub root_dir: PathBuf,
38 pub config: PipelineConfig,
39 pub all_fragments: Vec<Fragment>,
40 pub core_ids: FxHashSet<FragmentId>,
41 pub scoring_result: ScoringResult,
42 pub needs: Vec<InformationNeed>,
43 pub changed_files: Vec<PathBuf>,
44 pub deleted_files: Vec<String>,
45 pub renamed_files: Vec<(String, String)>,
46 pub preferred_revs: Vec<String>,
47 pub commit_message: Option<String>,
48 pub heavy_latency_ms: HeavyLatencyMs,
49}
50
51#[derive(Default, Clone, Copy)]
52pub struct HeavyLatencyMs {
53 pub parse_changed: f64,
54 pub universe_walk: f64,
55 pub discovery: f64,
56 pub parse_discovered: f64,
57 pub tokenization: f64,
58 pub graph_build: f64,
59 pub scoring: f64,
60}
61
62pub fn build_diff_context(
63 root_dir: &Path,
64 diff_range: Option<&str>,
65 budget_tokens: Option<u32>,
66 alpha: f64,
67 tau: f64,
68 no_content: bool,
69 full: bool,
70 scoring_mode: ScoringMode,
71 timeout: u64,
72) -> Result<DiffContextOutput> {
73 if full {
74 return build_diff_context_full(root_dir, diff_range, no_content, timeout);
75 }
76 let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
77 if state.all_fragments.is_empty() {
78 return Ok(empty_output_from_state(&state));
79 }
80 Ok(select_with_params(&state, budget_tokens, tau, no_content))
81}
82
83pub fn compute_scored_state(
87 root_dir: &Path,
88 diff_range: Option<&str>,
89 alpha: f64,
90 scoring_mode: ScoringMode,
91 timeout: u64,
92) -> Result<ScoredState> {
93 git::set_git_timeout(timeout);
94 let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
95 tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
96 root_dir.to_path_buf()
97 });
98
99 if !git::is_git_repo(&root_dir) {
100 anyhow::bail!("'{}' is not a git repository", root_dir.display());
101 }
102 let root_dir = git::find_toplevel(&root_dir).unwrap_or(root_dir);
103 if alpha <= 0.0 || alpha >= 1.0 {
104 anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
105 }
106
107 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
108
109 let is_working_tree_diff = matches!(diff_range, None | Some("HEAD"));
114 let mut untracked_files: Vec<PathBuf> = Vec::new();
115 if is_working_tree_diff {
116 if let Ok(files) = git::get_untracked_files(&root_dir) {
117 for f in &files {
118 if let Ok(content) = std::fs::read_to_string(f) {
119 let line_count = content.lines().count() as u32;
120 if line_count > 0 {
121 let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
122 hunks.push(crate::types::DiffHunk {
123 path: path_str,
124 new_start: 1,
125 new_len: line_count,
126 old_start: 0,
127 old_len: 0,
128 });
129 }
130 }
131 }
132 untracked_files = files;
133 }
134 }
135
136 hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
137
138 if hunks.is_empty() {
139 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
140 }
141
142 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
143 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
144
145 if hunks.is_empty() {
146 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
147 }
148
149 let diff_text = git::get_diff_text(&root_dir, diff_range)?;
150
151 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
152 changed_files.extend(untracked_files);
153 if changed_files.is_empty() {
154 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
155 }
156
157 let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
158 let (renamed_old, _pure_rename_new) = git::get_renamed_paths(
161 &root_dir,
162 diff_range,
163 GRAPH_FILTERING.git_rename_similarity_threshold,
164 )?;
165 let mut deleted_display: Vec<String> = deleted_files
169 .iter()
170 .map(|p| {
171 p.strip_prefix(&root_dir)
172 .unwrap_or(p)
173 .to_string_lossy()
174 .replace('\\', "/")
175 })
176 .collect();
177 deleted_display.sort();
178 let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
179 let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
180 let changed_files: Vec<PathBuf> = changed_files
181 .into_iter()
182 .filter(|f| {
183 let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
184 !excluded.contains(&resolved)
185 && !is_secret_path(f)
186 && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
187 })
188 .collect();
189
190 let (base_rev, head_rev) = diff_range
191 .map(git::split_diff_range)
192 .unwrap_or((None, None));
193 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
194 let commit_message = head_rev
195 .as_deref()
196 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
197 .and_then(|m| {
198 m.lines()
199 .map(str::trim)
200 .find(|l| !l.is_empty())
201 .map(str::to_string)
202 });
203
204 let t0 = Instant::now();
205
206 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
207 let mut batch_reader = CatFileBatch::new(&root_dir)?;
208 let mut all_fragments = process_files_for_fragments(
209 &changed_files,
210 &root_dir,
211 &preferred_revs,
212 &mut seen_frag_ids,
213 Some(&mut batch_reader),
214 true,
215 );
216
217 let t_parse_changed = Instant::now();
218
219 let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
220 let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
221
222 let t_universe = Instant::now();
223
224 let file_cache = build_file_cache(&all_candidate_files);
225 let mode = scoring_mode;
226 let mut config = PipelineConfig::from_mode(mode);
227 if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
228 config.objective = crate::mode::ObjectiveMode::from_str(&s);
229 }
230
231 let mut expansion_concepts: FxHashSet<String> =
232 crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
233 .into_iter()
234 .collect();
235
236 if let Some(ref h) = head_rev {
237 if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
238 if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
239 for ident in crate::types::extract_identifiers(
240 &commit_msg,
241 TOKENIZATION.query_min_identifier_length,
242 ) {
243 expansion_concepts.insert(ident);
244 }
245 }
246 }
247 }
248
249 let discovery_ctx = DiscoveryContext {
250 root_dir: root_dir.clone(),
251 changed_files: changed_files.clone(),
252 all_candidates: all_candidate_files,
253 diff_text: diff_text.clone(),
254 expansion_concepts,
255 file_cache,
256 token_corpus: std::sync::OnceLock::new(),
257 };
258
259 let discovered_files = create_discovery(&config).discover(&discovery_ctx);
260 let discovered_files: Vec<PathBuf> = discovered_files
261 .into_iter()
262 .map(|p| candidate_files::normalize_path(&p, &root_dir))
263 .collect();
264
265 drop(discovery_ctx);
266
267 let t_discovery = Instant::now();
268
269 all_fragments.extend(process_files_for_fragments(
270 &discovered_files,
271 &root_dir,
272 &preferred_revs,
273 &mut seen_frag_ids,
274 Some(&mut batch_reader),
275 false,
276 ));
277
278 let t_parse_discovered = Instant::now();
279
280 assign_token_counts(&mut all_fragments);
281
282 let core_ids = identify_core_fragments(&hunks, &all_fragments);
283
284 let signature_frags = generate_signature_variants(&all_fragments);
285 let mut sig_frags = signature_frags;
286 assign_token_counts(&mut sig_frags);
287 all_fragments.extend(sig_frags);
288
289 let t_tokenization = Instant::now();
290
291 let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
292
293 let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
294 .iter()
295 .map(|p| Arc::from(p.to_string_lossy().as_ref()))
296 .collect();
297
298 let strategy: Box<dyn ScoringStrategy> = match config.scoring {
299 ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
300 ScoringKind::Ppr => Box::new(PPRScoring::new(
301 config.ppr_alpha,
302 config.low_relevance_filter,
303 )),
304 ScoringKind::Bm25 => Box::new(BM25Scoring),
305 };
306
307 let scoring_result = strategy.score_and_filter(
308 &all_fragments,
309 &core_ids,
310 &hunks,
311 Some(root_dir.as_path()),
312 Some(&seed_weights),
313 Some(&discovered_path_set),
314 );
315
316 let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
317
318 let t_done = Instant::now();
319 batch_reader.close();
320
321 let graph_build_ms = scoring_result.graph_build_ms;
322 let heavy_latency_ms = HeavyLatencyMs {
323 parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
324 universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
325 discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
326 parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
327 tokenization: t_tokenization
328 .duration_since(t_parse_discovered)
329 .as_secs_f64()
330 * 1000.0,
331 graph_build: graph_build_ms,
332 scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
333 .max(0.0),
334 };
335
336 tracing::debug!(
337 "diffctx heavy: parse_changed {:.3}s, universe {:.3}s, discovery {:.3}s, parse_discovered {:.3}s, tokenization {:.3}s, graph_build {:.3}s, scoring {:.3}s",
338 heavy_latency_ms.parse_changed / 1000.0,
339 heavy_latency_ms.universe_walk / 1000.0,
340 heavy_latency_ms.discovery / 1000.0,
341 heavy_latency_ms.parse_discovered / 1000.0,
342 heavy_latency_ms.tokenization / 1000.0,
343 heavy_latency_ms.graph_build / 1000.0,
344 heavy_latency_ms.scoring / 1000.0,
345 );
346
347 Ok(ScoredState {
348 root_dir,
349 config,
350 all_fragments,
351 core_ids,
352 scoring_result,
353 needs,
354 changed_files,
355 deleted_files: deleted_display,
356 renamed_files: renamed_display,
357 preferred_revs,
358 commit_message,
359 heavy_latency_ms,
360 })
361}
362
363pub fn select_with_params(
371 state: &ScoredState,
372 budget_tokens: Option<u32>,
373 tau: f64,
374 no_content: bool,
375) -> DiffContextOutput {
376 let t_start = Instant::now();
377 let effective_budget = budget_tokens.unwrap_or_else(|| {
378 let core_tokens: u32 = state
379 .all_fragments
380 .iter()
381 .filter(|f| state.core_ids.contains(&f.id))
382 .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
383 .sum();
384 let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
385 auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
386 });
387
388 let selection_result = match state.config.objective {
389 crate::mode::ObjectiveMode::BoltzmannModular => {
390 let beta = crate::utility::calibrate_beta(
391 &state.scoring_result.filtered_fragments,
392 &state.core_ids,
393 &state.scoring_result.rel_scores,
394 effective_budget,
395 crate::config::selection::boltzmann().calibration_tolerance,
396 );
397 tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
398 crate::utility::boltzmann_select(
399 &state.scoring_result.filtered_fragments,
400 &state.core_ids,
401 &state.scoring_result.rel_scores,
402 effective_budget,
403 beta,
404 )
405 }
406 crate::mode::ObjectiveMode::Submodular => {
407 let file_importance =
408 crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
409 crate::select::lazy_greedy_select(
410 state.scoring_result.filtered_fragments.clone(),
411 &state.core_ids,
412 &state.scoring_result.rel_scores,
413 &state.needs,
414 effective_budget,
415 tau,
416 Some(&file_importance),
417 )
418 }
419 };
420
421 let selection_iters = selection_result.greedy_iters;
422 let stopping_certificate = selection_result.stopping_certificate;
423 let mut selected = selection_result.selected;
424
425 postpass::coherence_post_pass(
426 &mut selected,
427 &state.scoring_result.filtered_fragments,
428 &state.scoring_result.graph,
429 effective_budget,
430 );
431
432 postpass::rescue_nontrivial_context(
433 &mut selected,
434 &state.all_fragments,
435 &state.scoring_result.rel_scores,
436 &state.core_ids,
437 effective_budget,
438 );
439
440 let used: u32 = selected.iter().map(|f| f.token_count).sum();
441 let remaining = effective_budget.saturating_sub(used);
442 let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
443 Ok(r) => Some(r),
444 Err(_) => None,
445 };
446 postpass::ensure_changed_files_represented(
447 &mut selected,
448 &state.all_fragments,
449 &state.changed_files,
450 remaining,
451 &state.root_dir,
452 &state.preferred_revs,
453 batch_reader.as_mut(),
454 &state.core_ids,
455 );
456 if let Some(mut r) = batch_reader {
457 r.close();
458 }
459
460 let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
461 let total_ms = state.heavy_latency_ms.parse_changed
462 + state.heavy_latency_ms.universe_walk
463 + state.heavy_latency_ms.discovery
464 + state.heavy_latency_ms.parse_discovered
465 + state.heavy_latency_ms.tokenization
466 + state.heavy_latency_ms.graph_build
467 + state.heavy_latency_ms.scoring
468 + select_ms;
469
470 let cap_stats = state.scoring_result.graph.cap_stats.clone();
471 let change = render::ChangeSummary {
472 commit_message: state.commit_message.clone(),
473 changed_files: state
474 .changed_files
475 .iter()
476 .map(|p| {
477 p.strip_prefix(&state.root_dir)
478 .unwrap_or(p)
479 .to_string_lossy()
480 .replace('\\', "/")
481 })
482 .collect(),
483 deleted_files: state.deleted_files.clone(),
484 renamed_files: state.renamed_files.clone(),
485 };
486 let mut output = render::build_diff_context_output(
487 &state.root_dir,
488 &selected,
489 no_content,
490 &state.core_ids,
491 &state.scoring_result.rel_scores,
492 change,
493 );
494 output.latency = Some(render::LatencyBreakdown {
495 parse_changed_ms: state.heavy_latency_ms.parse_changed,
496 universe_walk_ms: state.heavy_latency_ms.universe_walk,
497 discovery_ms: state.heavy_latency_ms.discovery,
498 parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
499 tokenization_ms: state.heavy_latency_ms.tokenization,
500 graph_build_ms: state.heavy_latency_ms.graph_build,
501 scoring_selection_ms: state.heavy_latency_ms.graph_build
502 + state.heavy_latency_ms.scoring
503 + select_ms,
504 total_ms,
505 scoring_ms: state.heavy_latency_ms.scoring,
506 selection_ms: select_ms,
507 candidate_count: state.scoring_result.filtered_fragments.len(),
508 edge_count: state.scoring_result.graph.edge_count(),
509 greedy_iters: selection_iters,
510 edges_before_cap: cap_stats.edges_before_cap,
511 edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
512 nodes_capped: cap_stats.nodes_capped,
513 max_out_edges_per_node: cap_stats.max_out_edges_per_node,
514 ppr_truncated: state.scoring_result.ppr_truncated,
515 stopping_certificate,
516 ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
517 ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
518 peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
519 edge_emissions_by_category: cap_stats
520 .emissions_by_category
521 .iter()
522 .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
523 .collect(),
524 });
525 output
526}
527
528fn is_secret_path(path: &Path) -> bool {
537 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
538 return false;
539 };
540 if matches!(name, "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519") {
541 return true;
542 }
543 matches!(
544 path.extension().and_then(|e| e.to_str()),
545 Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks")
546 )
547}
548
549fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
550 path.strip_prefix(root_dir)
551 .ok()
552 .map(|p| p.to_string_lossy().replace('\\', "/"))
553}
554
555fn resolve_ignored_paths(root_dir: &Path, hunks: &[crate::types::DiffHunk]) -> FxHashSet<String> {
561 let rel_paths: Vec<String> = hunks
562 .iter()
563 .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
564 .collect();
565 git::find_ignored_paths(root_dir, &rel_paths)
566}
567
568fn is_ignored_path(root_dir: &Path, path: &Path, ignored_rel_paths: &FxHashSet<String>) -> bool {
569 rel_path_string(root_dir, path)
570 .map(|rel| ignored_rel_paths.contains(&rel))
571 .unwrap_or(false)
572}
573
574fn build_diff_context_full(
575 root_dir: &Path,
576 diff_range: Option<&str>,
577 no_content: bool,
578 timeout: u64,
579) -> Result<DiffContextOutput> {
580 git::set_git_timeout(timeout);
581 let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
582 tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
583 root_dir.to_path_buf()
584 });
585 if !git::is_git_repo(&root_dir) {
586 anyhow::bail!("'{}' is not a git repository", root_dir.display());
587 }
588 let root_dir = git::find_toplevel(&root_dir).unwrap_or(root_dir);
589 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
590 hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
591 if hunks.is_empty() {
592 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
593 let mut output = empty_output(&root_dir);
594 output.deleted_files = deleted;
595 output.renamed_files = renamed;
596 return Ok(output);
597 }
598 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
599 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
600 if hunks.is_empty() {
601 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
602 let mut output = empty_output(&root_dir);
603 output.deleted_files = deleted;
604 output.renamed_files = renamed;
605 return Ok(output);
606 }
607 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
608 changed_files
609 .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
610 if changed_files.is_empty() {
611 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
612 let mut output = empty_output(&root_dir);
613 output.deleted_files = deleted;
614 output.renamed_files = renamed;
615 return Ok(output);
616 }
617 let (base_rev, head_rev) = diff_range
618 .map(git::split_diff_range)
619 .unwrap_or((None, None));
620 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
621 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
622 let mut batch_reader = CatFileBatch::new(&root_dir)?;
623 let mut all_fragments = process_files_for_fragments(
624 &changed_files,
625 &root_dir,
626 &preferred_revs,
627 &mut seen_frag_ids,
628 Some(&mut batch_reader),
629 true,
630 );
631 assign_token_counts(&mut all_fragments);
632 let mut sig_frags = generate_signature_variants(&all_fragments);
633 assign_token_counts(&mut sig_frags);
634 all_fragments.extend(sig_frags);
635 changed_files.sort();
636 let core_ids = identify_core_fragments(&hunks, &all_fragments);
637 let selected = select_full_mode(&all_fragments, &changed_files);
638 batch_reader.close();
639 let commit_message = head_rev
640 .as_deref()
641 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
642 .and_then(|m| {
643 m.lines()
644 .map(str::trim)
645 .find(|l| !l.is_empty())
646 .map(str::to_string)
647 });
648 let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
649 .map(|set| {
650 set.iter()
651 .map(|p| {
652 p.strip_prefix(&root_dir)
653 .unwrap_or(p)
654 .to_string_lossy()
655 .replace('\\', "/")
656 })
657 .collect()
658 })
659 .unwrap_or_default();
660 deleted_display.sort();
661 let change = render::ChangeSummary {
662 commit_message,
663 changed_files: changed_files
664 .iter()
665 .map(|p| {
666 p.strip_prefix(&root_dir)
667 .unwrap_or(p)
668 .to_string_lossy()
669 .replace('\\', "/")
670 })
671 .collect(),
672 deleted_files: deleted_display,
673 renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
674 };
675 Ok(render::build_diff_context_output(
676 &root_dir,
677 &selected,
678 no_content,
679 &core_ids,
680 &FxHashMap::default(),
681 change,
682 ))
683}
684
685fn deletion_rename_displays(
686 root_dir: &Path,
687 diff_range: Option<&str>,
688) -> (Vec<String>, Vec<(String, String)>) {
689 let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
690 .map(|set| {
691 set.iter()
692 .map(|p| {
693 p.strip_prefix(root_dir)
694 .unwrap_or(p)
695 .to_string_lossy()
696 .replace('\\', "/")
697 })
698 .collect()
699 })
700 .unwrap_or_default();
701 deleted.sort();
702 let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
703 (deleted, renamed)
704}
705
706fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
707 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
708 let mut state = empty_scored_state(root_dir);
709 state.deleted_files = deleted;
710 state.renamed_files = renamed;
711 state
712}
713
714fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
715 let config = PipelineConfig::from_mode(ScoringMode::Ego);
716 ScoredState {
717 root_dir,
718 config,
719 all_fragments: Vec::new(),
720 core_ids: FxHashSet::default(),
721 scoring_result: ScoringResult {
722 rel_scores: FxHashMap::default(),
723 filtered_fragments: Vec::new(),
724 graph: crate::graph::Graph::new(),
725 graph_build_ms: 0.0,
726 ppr_truncated: false,
727 ppr_forward_pushes: 0,
728 ppr_backward_pushes: 0,
729 },
730 needs: Vec::new(),
731 changed_files: Vec::new(),
732 deleted_files: Vec::new(),
733 renamed_files: Vec::new(),
734 preferred_revs: Vec::new(),
735 commit_message: None,
736 heavy_latency_ms: HeavyLatencyMs::default(),
737 }
738}
739
740fn empty_output(root_dir: &Path) -> DiffContextOutput {
741 let resolved = root_dir
742 .canonicalize()
743 .unwrap_or_else(|_| root_dir.to_path_buf());
744 let name = resolved
745 .file_name()
746 .map(|n| n.to_string_lossy().to_string())
747 .unwrap_or_else(|| resolved.to_string_lossy().to_string());
748 DiffContextOutput {
749 name,
750 output_type: "diff_context".to_string(),
751 commit_message: None,
752 changed_files: Vec::new(),
753 deleted_files: Vec::new(),
754 renamed_files: Vec::new(),
755 fragment_count: 0,
756 fragments: Vec::new(),
757 latency: None,
758 }
759}
760
761pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
766 let mut output = empty_output(&state.root_dir);
767 output.commit_message = state.commit_message.clone();
768 output.deleted_files = state.deleted_files.clone();
769 output.renamed_files = state.renamed_files.clone();
770 output
771}
772
773fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
774 let mut revs = Vec::new();
775 if let Some(h) = head_rev {
776 revs.push(h.to_string());
777 }
778 if let Some(b) = base_rev {
779 if Some(b) != head_rev {
780 revs.push(b.to_string());
781 }
782 }
783 revs
784}
785
786fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
787 match config.discovery {
788 DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
789 Box::new(DefaultDiscovery),
790 Box::new(TestFileDiscovery),
791 Box::new(BM25Discovery::new(config.bm25_top_k)),
792 ])),
793 DiscoveryKind::Default => Box::new(DefaultDiscovery),
794 }
795}
796
797fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
798 let mut sorted = candidate_files.to_vec();
803 sorted.sort();
804 let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
805 let mut cache_bytes = 0usize;
806 for path in sorted {
807 if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
808 break;
809 }
810 let Ok(meta) = path.metadata() else { continue };
811 if meta.len() as usize > LIMITS.max_file_size {
812 continue;
813 }
814 if let Ok(content) = std::fs::read_to_string(&path) {
815 cache_bytes += content.len();
816 cache.insert(path, content);
817 }
818 }
819 cache
820}
821
822fn assign_token_counts(fragments: &mut [Fragment]) {
823 fragments.par_iter_mut().for_each(|frag| {
824 if frag.token_count == 0 {
825 frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
826 }
827 });
828}
829
830fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
831 let changed_paths: FxHashSet<String> = changed_files
832 .iter()
833 .map(|p| p.to_string_lossy().to_string())
834 .collect();
835 let mut selected: Vec<Fragment> = all_fragments
836 .iter()
837 .filter(|f| changed_paths.contains(f.path()))
838 .cloned()
839 .collect();
840 selected.sort_by(|a, b| {
841 a.path()
842 .cmp(b.path())
843 .then(a.start_line().cmp(&b.start_line()))
844 });
845 selected
846}