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