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 = resolve_repo_root(root_dir)?;
102 if alpha <= 0.0 || alpha >= 1.0 {
103 anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
104 }
105
106 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
107
108 let is_working_tree_diff = matches!(diff_range, None | Some("HEAD"));
113 let mut untracked_files: Vec<PathBuf> = Vec::new();
114 if is_working_tree_diff {
115 if let Ok(files) = git::get_untracked_files(&root_dir) {
116 for f in &files {
117 if let Ok(content) = std::fs::read_to_string(f) {
118 let line_count = content.lines().count() as u32;
119 if line_count > 0 {
120 let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
121 hunks.push(crate::types::DiffHunk {
122 path: path_str,
123 new_start: 1,
124 new_len: line_count,
125 old_start: 0,
126 old_len: 0,
127 });
128 }
129 }
130 }
131 untracked_files = files;
132 }
133 }
134
135 hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
136
137 if hunks.is_empty() {
138 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
139 }
140
141 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
142 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
143
144 let mut lockfile_display: Vec<String> = hunks
145 .iter()
146 .filter(|h| is_lockfile_path(Path::new(&*h.path)))
147 .filter_map(|h| rel_path_string(&root_dir, Path::new(&*h.path)))
148 .collect();
149 lockfile_display.sort();
150 lockfile_display.dedup();
151 hunks.retain(|h| !is_lockfile_path(Path::new(&*h.path)));
152
153 if hunks.is_empty() {
154 let mut state = empty_scored_state_with_changes(root_dir, diff_range);
155 state.lockfile_changes = lockfile_display;
156 return Ok(state);
157 }
158
159 let diff_text = git::get_diff_text(&root_dir, diff_range)?;
160
161 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
162 changed_files.extend(untracked_files);
163 if changed_files.is_empty() {
164 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
165 }
166
167 let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
168 let (renamed_old, _pure_rename_new) = git::get_renamed_paths(
171 &root_dir,
172 diff_range,
173 GRAPH_FILTERING.git_rename_similarity_threshold,
174 )?;
175 let mut deleted_display: Vec<String> = deleted_files
179 .iter()
180 .map(|p| {
181 p.strip_prefix(&root_dir)
182 .unwrap_or(p)
183 .to_string_lossy()
184 .replace('\\', "/")
185 })
186 .collect();
187 deleted_display.sort();
188 let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
189 let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
190 let changed_files: Vec<PathBuf> = changed_files
191 .into_iter()
192 .filter(|f| {
193 let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
194 !excluded.contains(&resolved)
195 && !is_secret_path(f)
196 && !is_lockfile_path(f)
197 && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
198 })
199 .collect();
200
201 let (base_rev, head_rev) = diff_range
202 .map(git::split_diff_range)
203 .unwrap_or((None, None));
204 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
205 let commit_message = head_rev
206 .as_deref()
207 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
208 .and_then(|m| {
209 m.lines()
210 .map(str::trim)
211 .find(|l| !l.is_empty())
212 .map(str::to_string)
213 });
214
215 let t0 = Instant::now();
216
217 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
218 let mut batch_reader = CatFileBatch::new(&root_dir)?;
219 let mut all_fragments = process_files_for_fragments(
220 &changed_files,
221 &root_dir,
222 &preferred_revs,
223 &mut seen_frag_ids,
224 Some(&mut batch_reader),
225 true,
226 );
227
228 let t_parse_changed = Instant::now();
229
230 let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
231 let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
232
233 let t_universe = Instant::now();
234
235 let file_cache = build_file_cache(&all_candidate_files);
236 let mode = scoring_mode;
237 let mut config = PipelineConfig::from_mode(mode);
238 if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
239 config.objective = crate::mode::ObjectiveMode::from_str(&s);
240 }
241
242 let mut expansion_concepts: FxHashSet<String> =
243 crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
244 .into_iter()
245 .collect();
246
247 if let Some(ref h) = head_rev {
248 if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
249 if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
250 for ident in crate::types::extract_identifiers(
251 &commit_msg,
252 TOKENIZATION.query_min_identifier_length,
253 ) {
254 expansion_concepts.insert(ident);
255 }
256 }
257 }
258 }
259
260 let discovery_ctx = DiscoveryContext {
261 root_dir: root_dir.clone(),
262 changed_files: changed_files.clone(),
263 all_candidates: all_candidate_files,
264 diff_text: diff_text.clone(),
265 expansion_concepts,
266 file_cache,
267 token_corpus: std::sync::OnceLock::new(),
268 };
269
270 let discovered_files = create_discovery(&config).discover(&discovery_ctx);
271 let discovered_files: Vec<PathBuf> = discovered_files
272 .into_iter()
273 .map(|p| candidate_files::normalize_path(&p, &root_dir))
274 .collect();
275
276 drop(discovery_ctx);
277
278 let t_discovery = Instant::now();
279
280 all_fragments.extend(process_files_for_fragments(
281 &discovered_files,
282 &root_dir,
283 &preferred_revs,
284 &mut seen_frag_ids,
285 Some(&mut batch_reader),
286 false,
287 ));
288
289 let t_parse_discovered = Instant::now();
290
291 assign_token_counts(&mut all_fragments);
292
293 let core_ids = identify_core_fragments(&hunks, &all_fragments);
294
295 let mut core_excerpts =
296 crate::excerpt::generate_core_excerpts(&all_fragments, &core_ids, &hunks);
297 assign_excerpt_token_counts(&mut core_excerpts);
298
299 let signature_frags = generate_signature_variants(&all_fragments);
300 let mut sig_frags = signature_frags;
301 assign_token_counts(&mut sig_frags);
302 all_fragments.extend(sig_frags);
303
304 let t_tokenization = Instant::now();
305
306 let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
307
308 let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
309 .iter()
310 .map(|p| Arc::from(p.to_string_lossy().as_ref()))
311 .collect();
312
313 let strategy: Box<dyn ScoringStrategy> = match config.scoring {
314 ScoringKind::Ego => Box::new(EgoGraphScoring::new(config.ego_depth)),
315 ScoringKind::Ppr => Box::new(PPRScoring::new(
316 config.ppr_alpha,
317 config.low_relevance_filter,
318 )),
319 ScoringKind::Bm25 => Box::new(BM25Scoring),
320 };
321
322 let scoring_result = strategy.score_and_filter(
323 &all_fragments,
324 &core_ids,
325 &hunks,
326 Some(root_dir.as_path()),
327 Some(&seed_weights),
328 Some(&discovered_path_set),
329 );
330
331 let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
332
333 let t_done = Instant::now();
334 batch_reader.close();
335
336 let graph_build_ms = scoring_result.graph_build_ms;
337 let heavy_latency_ms = HeavyLatencyMs {
338 parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
339 universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
340 discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
341 parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
342 tokenization: t_tokenization
343 .duration_since(t_parse_discovered)
344 .as_secs_f64()
345 * 1000.0,
346 graph_build: graph_build_ms,
347 scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
348 .max(0.0),
349 };
350
351 tracing::debug!(
352 "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",
353 heavy_latency_ms.parse_changed / 1000.0,
354 heavy_latency_ms.universe_walk / 1000.0,
355 heavy_latency_ms.discovery / 1000.0,
356 heavy_latency_ms.parse_discovered / 1000.0,
357 heavy_latency_ms.tokenization / 1000.0,
358 heavy_latency_ms.graph_build / 1000.0,
359 heavy_latency_ms.scoring / 1000.0,
360 );
361
362 Ok(ScoredState {
363 root_dir,
364 config,
365 all_fragments,
366 core_ids,
367 core_excerpts,
368 scoring_result,
369 needs,
370 changed_files,
371 deleted_files: deleted_display,
372 renamed_files: renamed_display,
373 lockfile_changes: lockfile_display,
374 preferred_revs,
375 commit_message,
376 heavy_latency_ms,
377 })
378}
379
380pub fn select_with_params(
388 state: &ScoredState,
389 budget_tokens: Option<u32>,
390 tau: f64,
391 no_content: bool,
392) -> DiffContextOutput {
393 let t_start = Instant::now();
394 let effective_budget = budget_tokens.unwrap_or_else(|| {
395 let core_tokens: u32 = state
396 .all_fragments
397 .iter()
398 .filter(|f| state.core_ids.contains(&f.id))
399 .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
400 .sum();
401 let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
402 auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
403 });
404
405 let selection_result = match state.config.objective {
406 crate::mode::ObjectiveMode::BoltzmannModular => {
407 let beta = crate::utility::calibrate_beta(
408 &state.scoring_result.filtered_fragments,
409 &state.core_ids,
410 &state.scoring_result.rel_scores,
411 effective_budget,
412 crate::config::selection::boltzmann().calibration_tolerance,
413 );
414 tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
415 crate::utility::boltzmann_select(
416 &state.scoring_result.filtered_fragments,
417 &state.core_ids,
418 &state.scoring_result.rel_scores,
419 effective_budget,
420 beta,
421 )
422 }
423 crate::mode::ObjectiveMode::Submodular => {
424 let file_importance =
425 crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
426 crate::select::lazy_greedy_select(
427 state.scoring_result.filtered_fragments.clone(),
428 &state.core_ids,
429 &state.scoring_result.rel_scores,
430 &state.needs,
431 effective_budget,
432 tau,
433 Some(&file_importance),
434 Some(&state.core_excerpts),
435 )
436 }
437 };
438
439 let selection_iters = selection_result.greedy_iters;
440 let stopping_certificate = selection_result.stopping_certificate;
441 let mut selected = selection_result.selected;
442
443 postpass::coherence_post_pass(
444 &mut selected,
445 &state.scoring_result.filtered_fragments,
446 &state.scoring_result.graph,
447 effective_budget,
448 );
449
450 postpass::rescue_nontrivial_context(
451 &mut selected,
452 &state.all_fragments,
453 &state.scoring_result.rel_scores,
454 &state.core_ids,
455 effective_budget,
456 );
457
458 let used: u32 = selected.iter().map(|f| f.token_count).sum();
459 let remaining = effective_budget.saturating_sub(used);
460 let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
461 Ok(r) => Some(r),
462 Err(_) => None,
463 };
464 postpass::ensure_changed_files_represented(
465 &mut selected,
466 &state.all_fragments,
467 &state.changed_files,
468 remaining,
469 &state.root_dir,
470 &state.preferred_revs,
471 batch_reader.as_mut(),
472 &state.core_ids,
473 &state.core_excerpts,
474 );
475 if let Some(mut r) = batch_reader {
476 r.close();
477 }
478
479 let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
480 let total_ms = state.heavy_latency_ms.parse_changed
481 + state.heavy_latency_ms.universe_walk
482 + state.heavy_latency_ms.discovery
483 + state.heavy_latency_ms.parse_discovered
484 + state.heavy_latency_ms.tokenization
485 + state.heavy_latency_ms.graph_build
486 + state.heavy_latency_ms.scoring
487 + select_ms;
488
489 let cap_stats = state.scoring_result.graph.cap_stats.clone();
490 let change = render::ChangeSummary {
491 commit_message: state.commit_message.clone(),
492 changed_files: state
493 .changed_files
494 .iter()
495 .map(|p| {
496 p.strip_prefix(&state.root_dir)
497 .unwrap_or(p)
498 .to_string_lossy()
499 .replace('\\', "/")
500 })
501 .collect(),
502 deleted_files: state.deleted_files.clone(),
503 renamed_files: state.renamed_files.clone(),
504 lockfile_changes: state.lockfile_changes.clone(),
505 };
506 let mut render_core_ids = state.core_ids.clone();
510 render_core_ids.extend(
511 selected
512 .iter()
513 .filter(|f| f.kind == crate::types::FragmentKind::Excerpt)
514 .map(|f| f.id.clone()),
515 );
516
517 let mut output = render::build_diff_context_output(
518 &state.root_dir,
519 &selected,
520 no_content,
521 &render_core_ids,
522 &state.scoring_result.rel_scores,
523 change,
524 );
525 output.latency = Some(render::LatencyBreakdown {
526 parse_changed_ms: state.heavy_latency_ms.parse_changed,
527 universe_walk_ms: state.heavy_latency_ms.universe_walk,
528 discovery_ms: state.heavy_latency_ms.discovery,
529 parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
530 tokenization_ms: state.heavy_latency_ms.tokenization,
531 graph_build_ms: state.heavy_latency_ms.graph_build,
532 scoring_selection_ms: state.heavy_latency_ms.graph_build
533 + state.heavy_latency_ms.scoring
534 + select_ms,
535 total_ms,
536 scoring_ms: state.heavy_latency_ms.scoring,
537 selection_ms: select_ms,
538 candidate_count: state.scoring_result.filtered_fragments.len(),
539 edge_count: state.scoring_result.graph.edge_count(),
540 greedy_iters: selection_iters,
541 edges_before_cap: cap_stats.edges_before_cap,
542 edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
543 nodes_capped: cap_stats.nodes_capped,
544 max_out_edges_per_node: cap_stats.max_out_edges_per_node,
545 ppr_truncated: state.scoring_result.ppr_truncated,
546 stopping_certificate,
547 ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
548 ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
549 peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
550 edge_emissions_by_category: cap_stats
551 .emissions_by_category
552 .iter()
553 .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
554 .collect(),
555 });
556 output
557}
558
559pub(crate) fn is_secret_path(path: &Path) -> bool {
568 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
569 return false;
570 };
571 if matches!(name, "id_rsa" | "id_dsa" | "id_ecdsa" | "id_ed25519") {
572 return true;
573 }
574 matches!(
575 path.extension().and_then(|e| e.to_str()),
576 Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks")
577 )
578}
579
580fn is_lockfile_path(path: &Path) -> bool {
586 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
587 return false;
588 };
589 matches!(
590 name,
591 "Cargo.lock"
592 | "package-lock.json"
593 | "npm-shrinkwrap.json"
594 | "yarn.lock"
595 | "pnpm-lock.yaml"
596 | "bun.lock"
597 | "bun.lockb"
598 | "deno.lock"
599 | "Pipfile.lock"
600 | "poetry.lock"
601 | "uv.lock"
602 | "pdm.lock"
603 | "composer.lock"
604 | "Gemfile.lock"
605 | "flake.lock"
606 | "go.sum"
607 | "mix.lock"
608 | "packages.lock.json"
609 | "gradle.lockfile"
610 | "Package.resolved"
611 | "cabal.project.freeze"
612 )
613}
614
615pub(crate) fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
616 path.strip_prefix(root_dir)
617 .ok()
618 .map(|p| p.to_string_lossy().replace('\\', "/"))
619}
620
621fn resolve_ignored_paths(root_dir: &Path, hunks: &[crate::types::DiffHunk]) -> FxHashSet<String> {
627 let rel_paths: Vec<String> = hunks
628 .iter()
629 .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
630 .collect();
631 git::find_ignored_paths(root_dir, &rel_paths)
632}
633
634pub(crate) fn is_ignored_path(
635 root_dir: &Path,
636 path: &Path,
637 ignored_rel_paths: &FxHashSet<String>,
638) -> bool {
639 rel_path_string(root_dir, path)
640 .map(|rel| ignored_rel_paths.contains(&rel))
641 .unwrap_or(false)
642}
643
644pub fn raw_diff_text(root_dir: &Path, diff_range: Option<&str>, timeout: u64) -> Result<String> {
649 git::set_git_timeout(timeout);
650 let root_dir = resolve_repo_root(root_dir)?;
651 let diff_text = git::get_diff_text(&root_dir, diff_range)?;
652 Ok(keep_disclosable_sections(&root_dir, &diff_text))
653}
654
655fn keep_disclosable_sections(root_dir: &Path, diff_text: &str) -> String {
656 let raw: Vec<&str> = diff_text.split_inclusive('\n').collect();
662 let lines: Vec<&str> = raw
663 .iter()
664 .map(|line| line.trim_end_matches('\n').trim_end_matches('\r'))
665 .collect();
666 let sections = split_diff_sections(root_dir, &lines);
667 let rel_paths: Vec<String> = sections
668 .iter()
669 .filter_map(|(path, _)| path.as_deref())
670 .filter_map(|path| rel_path_string(root_dir, path))
671 .collect();
672 let ignored_rel_paths = git::find_ignored_paths(root_dir, &rel_paths);
673
674 let mut kept: Vec<&str> = Vec::new();
675 for (path, range) in sections {
676 let Some(path) = path else {
680 continue;
681 };
682 if is_secret_path(&path)
683 || is_lockfile_path(&path)
684 || is_ignored_path(root_dir, &path, &ignored_rel_paths)
685 {
686 continue;
687 }
688 kept.extend_from_slice(&raw[range]);
689 }
690 if kept.is_empty() {
691 return String::new();
692 }
693 let mut text = kept.concat();
694 if !text.ends_with('\n') {
695 text.push('\n');
696 }
697 text
698}
699
700type DiffSection = (Option<PathBuf>, std::ops::Range<usize>);
701
702fn split_diff_sections(root_dir: &Path, lines: &[&str]) -> Vec<DiffSection> {
703 let starts: Vec<usize> = lines
704 .iter()
705 .enumerate()
706 .filter(|(_, line)| line.starts_with("diff --git "))
707 .map(|(index, _)| index)
708 .collect();
709 starts
710 .iter()
711 .enumerate()
712 .map(|(nth, &start)| {
713 let end = starts.get(nth + 1).copied().unwrap_or(lines.len());
714 (section_path(root_dir, &lines[start..end]), start..end)
715 })
716 .collect()
717}
718
719fn section_path(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
720 let mut old_path: Option<PathBuf> = None;
721 let mut new_path: Option<PathBuf> = None;
722 for line in section.iter().take_while(|line| !line.starts_with("@@")) {
723 match git::parse_path_line(line, root_dir) {
724 ("new", path) => new_path = path,
725 ("old", path) => old_path = path,
726 _ => {}
727 }
728 }
729 new_path
730 .or(old_path)
731 .or_else(|| pathless_section(root_dir, section))
732}
733
734fn pathless_section(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
740 if let Some(quoted) = section
741 .iter()
742 .find_map(|line| line.strip_prefix("rename to "))
743 {
744 return contained_path(root_dir, &git::unquote_c_style(quoted.trim()));
745 }
746 let rest = section.first()?.strip_prefix("diff --git ")?;
747 let rel_path = rest.get("a/".len()..rest.find(" b/")?)?;
748 if rest != format!("a/{rel_path} b/{rel_path}") {
749 return None;
750 }
751 contained_path(root_dir, rel_path)
752}
753
754fn contained_path(root_dir: &Path, rel_path: &str) -> Option<PathBuf> {
755 let joined = root_dir.join(rel_path);
756 let resolved = joined.canonicalize().unwrap_or_else(|_| joined.clone());
757 let resolved_root = root_dir
758 .canonicalize()
759 .unwrap_or_else(|_| root_dir.to_path_buf());
760 resolved.starts_with(&resolved_root).then_some(joined)
761}
762
763fn resolve_repo_root(root_dir: &Path) -> Result<PathBuf> {
764 let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
765 tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
766 root_dir.to_path_buf()
767 });
768 if !git::is_git_repo(&root_dir) {
769 anyhow::bail!("'{}' is not a git repository", root_dir.display());
770 }
771 Ok(git::find_toplevel(&root_dir).unwrap_or(root_dir))
772}
773
774fn build_diff_context_full(
775 root_dir: &Path,
776 diff_range: Option<&str>,
777 no_content: bool,
778 timeout: u64,
779) -> Result<DiffContextOutput> {
780 git::set_git_timeout(timeout);
781 let root_dir = resolve_repo_root(root_dir)?;
782 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
783 hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
784 if hunks.is_empty() {
785 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
786 let mut output = empty_output(&root_dir);
787 output.deleted_files = deleted;
788 output.renamed_files = renamed;
789 return Ok(output);
790 }
791 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
792 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
793 if hunks.is_empty() {
794 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
795 let mut output = empty_output(&root_dir);
796 output.deleted_files = deleted;
797 output.renamed_files = renamed;
798 return Ok(output);
799 }
800 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
801 changed_files
802 .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
803 if changed_files.is_empty() {
804 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
805 let mut output = empty_output(&root_dir);
806 output.deleted_files = deleted;
807 output.renamed_files = renamed;
808 return Ok(output);
809 }
810 let (base_rev, head_rev) = diff_range
811 .map(git::split_diff_range)
812 .unwrap_or((None, None));
813 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
814 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
815 let mut batch_reader = CatFileBatch::new(&root_dir)?;
816 let mut all_fragments = process_files_for_fragments(
817 &changed_files,
818 &root_dir,
819 &preferred_revs,
820 &mut seen_frag_ids,
821 Some(&mut batch_reader),
822 true,
823 );
824 assign_token_counts(&mut all_fragments);
825 let mut sig_frags = generate_signature_variants(&all_fragments);
826 assign_token_counts(&mut sig_frags);
827 all_fragments.extend(sig_frags);
828 changed_files.sort();
829 let core_ids = identify_core_fragments(&hunks, &all_fragments);
830 let selected = select_full_mode(&all_fragments, &changed_files);
831 batch_reader.close();
832 let commit_message = head_rev
833 .as_deref()
834 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
835 .and_then(|m| {
836 m.lines()
837 .map(str::trim)
838 .find(|l| !l.is_empty())
839 .map(str::to_string)
840 });
841 let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
842 .map(|set| {
843 set.iter()
844 .map(|p| {
845 p.strip_prefix(&root_dir)
846 .unwrap_or(p)
847 .to_string_lossy()
848 .replace('\\', "/")
849 })
850 .collect()
851 })
852 .unwrap_or_default();
853 deleted_display.sort();
854 let change = render::ChangeSummary {
855 commit_message,
856 changed_files: changed_files
857 .iter()
858 .map(|p| {
859 p.strip_prefix(&root_dir)
860 .unwrap_or(p)
861 .to_string_lossy()
862 .replace('\\', "/")
863 })
864 .collect(),
865 deleted_files: deleted_display,
866 renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
867 lockfile_changes: Vec::new(),
870 };
871 Ok(render::build_diff_context_output(
872 &root_dir,
873 &selected,
874 no_content,
875 &core_ids,
876 &FxHashMap::default(),
877 change,
878 ))
879}
880
881fn deletion_rename_displays(
882 root_dir: &Path,
883 diff_range: Option<&str>,
884) -> (Vec<String>, Vec<(String, String)>) {
885 let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
886 .map(|set| {
887 set.iter()
888 .map(|p| {
889 p.strip_prefix(root_dir)
890 .unwrap_or(p)
891 .to_string_lossy()
892 .replace('\\', "/")
893 })
894 .collect()
895 })
896 .unwrap_or_default();
897 deleted.sort();
898 let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
899 (deleted, renamed)
900}
901
902fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
903 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
904 let mut state = empty_scored_state(root_dir);
905 state.deleted_files = deleted;
906 state.renamed_files = renamed;
907 state
908}
909
910fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
911 let config = PipelineConfig::from_mode(ScoringMode::Ego);
912 ScoredState {
913 root_dir,
914 config,
915 all_fragments: Vec::new(),
916 core_ids: FxHashSet::default(),
917 core_excerpts: FxHashMap::default(),
918 lockfile_changes: Vec::new(),
919 scoring_result: ScoringResult {
920 rel_scores: FxHashMap::default(),
921 filtered_fragments: Vec::new(),
922 graph: crate::graph::Graph::new(),
923 graph_build_ms: 0.0,
924 ppr_truncated: false,
925 ppr_forward_pushes: 0,
926 ppr_backward_pushes: 0,
927 },
928 needs: Vec::new(),
929 changed_files: Vec::new(),
930 deleted_files: Vec::new(),
931 renamed_files: Vec::new(),
932 preferred_revs: Vec::new(),
933 commit_message: None,
934 heavy_latency_ms: HeavyLatencyMs::default(),
935 }
936}
937
938fn empty_output(root_dir: &Path) -> DiffContextOutput {
939 let resolved = root_dir
940 .canonicalize()
941 .unwrap_or_else(|_| root_dir.to_path_buf());
942 let name = resolved
943 .file_name()
944 .map(|n| n.to_string_lossy().to_string())
945 .unwrap_or_else(|| resolved.to_string_lossy().to_string());
946 DiffContextOutput {
947 name,
948 output_type: "diff_context".to_string(),
949 commit_message: None,
950 changed_files: Vec::new(),
951 deleted_files: Vec::new(),
952 renamed_files: Vec::new(),
953 lockfile_changes: Vec::new(),
954 fragment_count: 0,
955 fragments: Vec::new(),
956 latency: None,
957 }
958}
959
960pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
965 let mut output = empty_output(&state.root_dir);
966 output.commit_message = state.commit_message.clone();
967 output.deleted_files = state.deleted_files.clone();
968 output.renamed_files = state.renamed_files.clone();
969 output.lockfile_changes = state.lockfile_changes.clone();
970 output
971}
972
973fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
974 let mut revs = Vec::new();
975 if let Some(h) = head_rev {
976 revs.push(h.to_string());
977 }
978 if let Some(b) = base_rev {
979 if Some(b) != head_rev {
980 revs.push(b.to_string());
981 }
982 }
983 revs
984}
985
986fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
987 match config.discovery {
988 DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
989 Box::new(DefaultDiscovery),
990 Box::new(TestFileDiscovery),
991 Box::new(BM25Discovery::new(config.bm25_top_k)),
992 ])),
993 DiscoveryKind::Default => Box::new(DefaultDiscovery),
994 }
995}
996
997fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
998 let mut sorted = candidate_files.to_vec();
1003 sorted.sort();
1004 let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
1005 let mut cache_bytes = 0usize;
1006 for path in sorted {
1007 if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
1008 break;
1009 }
1010 let Ok(meta) = path.metadata() else { continue };
1011 if meta.len() as usize > LIMITS.max_file_size {
1012 continue;
1013 }
1014 if let Ok(content) = std::fs::read_to_string(&path) {
1015 cache_bytes += content.len();
1016 cache.insert(path, content);
1017 }
1018 }
1019 cache
1020}
1021
1022fn assign_token_counts(fragments: &mut [Fragment]) {
1023 fragments.par_iter_mut().for_each(|frag| {
1024 if frag.token_count == 0 {
1025 frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1026 }
1027 });
1028}
1029
1030fn assign_excerpt_token_counts(excerpts: &mut FxHashMap<FragmentId, Fragment>) {
1031 for frag in excerpts.values_mut() {
1032 if frag.token_count == 0 {
1033 frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1034 }
1035 }
1036}
1037
1038fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
1039 let changed_paths: FxHashSet<String> = changed_files
1040 .iter()
1041 .map(|p| p.to_string_lossy().to_string())
1042 .collect();
1043 let mut selected: Vec<Fragment> = all_fragments
1044 .iter()
1045 .filter(|f| changed_paths.contains(f.path()))
1046 .cloned()
1047 .collect();
1048 selected.sort_by(|a, b| {
1049 a.path()
1050 .cmp(b.path())
1051 .then(a.start_line().cmp(&b.start_line()))
1052 });
1053 selected
1054}