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, ScoringMode};
22use crate::postpass;
23use crate::render::{self, DiffContextOutput};
24use crate::scoring::{ScoringResult, create_scoring_strategy};
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 ignored_changes: Vec<String>,
59 pub policy_excluded_count: usize,
64 pub discovery_source: FxHashMap<Arc<str>, &'static str>,
71 pub preferred_revs: Vec<String>,
72 pub commit_message: Option<String>,
73 pub heavy_latency_ms: HeavyLatencyMs,
74}
75
76#[derive(Default, Clone, Copy)]
77pub struct HeavyLatencyMs {
78 pub pre_phase: f64,
83 pub parse_changed: f64,
84 pub universe_walk: f64,
85 pub discovery: f64,
86 pub parse_discovered: f64,
87 pub tokenization: f64,
88 pub graph_build: f64,
89 pub scoring: f64,
90}
91
92pub fn build_diff_context(
93 root_dir: &Path,
94 diff_range: Option<&str>,
95 budget_tokens: Option<u32>,
96 alpha: f64,
97 tau: f64,
98 no_content: bool,
99 full: bool,
100 scoring_mode: ScoringMode,
101 timeout: u64,
102) -> Result<DiffContextOutput> {
103 if full {
104 return build_diff_context_full(root_dir, diff_range, no_content, timeout);
105 }
106 let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
107 if state.all_fragments.is_empty() {
108 return Ok(empty_output_from_state(&state));
109 }
110 Ok(select_with_params(&state, budget_tokens, tau, no_content))
111}
112
113pub fn build_diff_context_locate(
117 root_dir: &Path,
118 diff_range: Option<&str>,
119 budget_tokens: Option<u32>,
120 alpha: f64,
121 tau: f64,
122 scoring_mode: ScoringMode,
123 timeout: u64,
124) -> Result<crate::locate::LocateOutput> {
125 let state = compute_scored_state(root_dir, diff_range, alpha, scoring_mode, timeout)?;
126 let outcome = if state.all_fragments.is_empty() {
127 SelectionOutcome {
128 selected: Vec::new(),
129 effective_budget: budget_tokens.unwrap_or(0),
130 selection_iters: 0,
131 stopping_certificate: 0.0,
132 select_ms: 0.0,
133 }
134 } else {
135 run_selection(&state, budget_tokens, tau)
136 };
137 Ok(crate::locate::build_locate(&state, &outcome))
138}
139
140fn count_text_lines(path: &Path) -> Option<u32> {
162 use std::io::Read;
163
164 const CHUNK: usize = 64 * 1024;
165
166 let mut file = std::fs::File::open(path).ok()?;
167 let mut buf = vec![0u8; CHUNK];
168 let mut carry: Vec<u8> = Vec::new();
169 let mut newlines: u32 = 0;
170 let mut last_byte: Option<u8> = None;
171
172 loop {
173 let read = file.read(&mut buf).ok()?;
174 if read == 0 {
175 break;
176 }
177 carry.extend_from_slice(&buf[..read]);
178 let valid_upto = match std::str::from_utf8(&carry) {
179 Ok(_) => carry.len(),
180 Err(e) if e.error_len().is_none() => e.valid_up_to(),
183 Err(_) => return None,
184 };
185 newlines = newlines
186 .saturating_add(carry[..valid_upto].iter().filter(|b| **b == b'\n').count() as u32);
187 if let Some(&b) = carry[..valid_upto].last() {
188 last_byte = Some(b);
189 }
190 carry.drain(..valid_upto);
191 }
192
193 if !carry.is_empty() {
196 return None;
197 }
198 Some(match last_byte {
201 None => 0,
202 Some(b'\n') => newlines,
203 Some(_) => newlines.saturating_add(1),
204 })
205}
206
207pub fn compute_scored_state(
211 root_dir: &Path,
212 diff_range: Option<&str>,
213 alpha: f64,
214 scoring_mode: ScoringMode,
215 timeout: u64,
216) -> Result<ScoredState> {
217 let t_entry = Instant::now();
218 git::set_git_timeout(timeout);
219 let deadline = crate::deadline::Deadline::from_timeout_secs(timeout);
220 let root_dir = resolve_repo_root(root_dir)?;
221 if alpha <= 0.0 || alpha >= 1.0 {
222 anyhow::bail!("alpha must be in (0, 1), got {}", alpha);
223 }
224
225 let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
226 let diff_range = resolved.range.as_deref();
227
228 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
229
230 let is_working_tree_diff = resolved.from_duration || matches!(diff_range, None | Some("HEAD"));
236 let mut untracked_files: Vec<PathBuf> = Vec::new();
237 if is_working_tree_diff {
238 if let Ok(files) = git::get_untracked_files(&root_dir) {
239 for f in &files {
240 if let Some(line_count) = count_text_lines(f) {
241 if line_count > 0 {
242 let path_str: Arc<str> = Arc::from(f.to_string_lossy().as_ref());
243 hunks.push(crate::types::DiffHunk {
244 path: path_str,
245 new_start: 1,
246 new_len: line_count,
247 old_start: 0,
248 old_len: 0,
249 });
250 }
251 }
252 }
253 untracked_files = files;
254 }
255 }
256
257 let mut secret_excluded_paths: FxHashSet<String> = FxHashSet::default();
262 hunks.retain(|h| {
263 let p = Path::new(&*h.path);
264 if is_secret_path(p) {
265 if let Some(rel) = rel_path_string(&root_dir, p) {
266 secret_excluded_paths.insert(rel);
267 }
268 false
269 } else {
270 true
271 }
272 });
273
274 if hunks.is_empty() {
275 let mut state = empty_scored_state_with_changes(root_dir, diff_range);
276 state.policy_excluded_count = secret_excluded_paths.len();
277 return Ok(state);
278 }
279
280 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
281 let mut ignored_display: Vec<String> = Vec::new();
287 let mut policy_excluded_paths: FxHashSet<String> = FxHashSet::default();
290 for h in &hunks {
291 let p = Path::new(&*h.path);
292 let Some(rel) = rel_path_string(&root_dir, p) else {
293 continue;
294 };
295 match ignored_rel_paths.get(&rel) {
296 Some(git::IgnoreSource::Gitignore) => ignored_display.push(rel),
297 Some(git::IgnoreSource::DiffctxPolicy) => {
298 policy_excluded_paths.insert(rel);
299 }
300 None => {}
301 }
302 }
303 let policy_excluded = policy_excluded_paths.len() + secret_excluded_paths.len();
304 ignored_display.sort();
305 ignored_display.dedup();
306 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
307
308 let mut lockfile_display: Vec<String> = hunks
309 .iter()
310 .filter(|h| is_lockfile_path(Path::new(&*h.path)))
311 .filter_map(|h| rel_path_string(&root_dir, Path::new(&*h.path)))
312 .collect();
313 lockfile_display.sort();
314 lockfile_display.dedup();
315 hunks.retain(|h| !is_lockfile_path(Path::new(&*h.path)));
316
317 if hunks.is_empty() {
318 let mut state = empty_scored_state_with_changes(root_dir, diff_range);
319 state.lockfile_changes = lockfile_display;
320 state.ignored_changes = ignored_display;
321 state.policy_excluded_count = policy_excluded;
322 return Ok(state);
323 }
324
325 let diff_text = git::get_diff_text(&root_dir, diff_range)?;
326
327 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
328 changed_files.extend(untracked_files);
329 if changed_files.is_empty() {
330 return Ok(empty_scored_state_with_changes(root_dir, diff_range));
331 }
332
333 let deleted_files = git::get_deleted_files(&root_dir, diff_range)?;
334 let renamed_old = git::get_renamed_paths(&root_dir, diff_range)?;
338 let mut deleted_display: Vec<String> = deleted_files
342 .iter()
343 .map(|p| {
344 p.strip_prefix(&root_dir)
345 .unwrap_or(p)
346 .to_string_lossy()
347 .replace('\\', "/")
348 })
349 .collect();
350 deleted_display.sort();
351 let renamed_display = git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default();
352 let excluded: FxHashSet<PathBuf> = deleted_files.into_iter().chain(renamed_old).collect();
353 let changed_files: Vec<PathBuf> = changed_files
354 .into_iter()
355 .filter(|f| {
356 let resolved = f.canonicalize().unwrap_or_else(|_| f.clone());
357 !excluded.contains(&resolved)
358 && !is_secret_path(f)
359 && !is_lockfile_path(f)
360 && !is_ignored_path(&root_dir, f, &ignored_rel_paths)
361 })
362 .collect();
363
364 let (base_rev, head_rev) = diff_range
365 .map(git::split_diff_range)
366 .unwrap_or((None, None));
367 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
368 let commit_message = head_rev
369 .as_deref()
370 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
371 .and_then(|m| {
372 m.lines()
373 .map(str::trim)
374 .find(|l| !l.is_empty())
375 .map(str::to_string)
376 });
377
378 let t0 = Instant::now();
379 let pre_phase_ms = t0.duration_since(t_entry).as_secs_f64() * 1000.0;
380
381 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
382 let mut batch_reader = CatFileBatch::new(&root_dir)?;
383 let mut all_fragments = process_files_for_fragments(
384 &changed_files,
385 &root_dir,
386 &preferred_revs,
387 &mut seen_frag_ids,
388 Some(&mut batch_reader),
389 true,
390 );
391
392 let t_parse_changed = Instant::now();
393
394 let included_set: FxHashSet<PathBuf> = changed_files.iter().cloned().collect();
395 let all_candidate_files = candidate_files::collect_candidate_files(&root_dir, &included_set);
396
397 let t_universe = Instant::now();
398
399 let file_cache = build_file_cache(&all_candidate_files);
400 let mode = scoring_mode;
401 let mut config = PipelineConfig::from_mode(mode);
402 if let Ok(s) = std::env::var("DIFFCTX_OBJECTIVE") {
403 config.objective = crate::mode::ObjectiveMode::from_str(&s);
404 }
405
406 let mut expansion_concepts: FxHashSet<String> =
407 crate::types::extract_identifiers(&diff_text, TOKENIZATION.query_min_identifier_length)
408 .into_iter()
409 .collect();
410
411 if let Some(ref h) = head_rev {
412 if std::env::var("DIFFCTX_NO_COMMIT_SIGNAL").as_deref() != Ok("1") {
413 if let Ok(commit_msg) = git::get_commit_message(&root_dir, h) {
414 for ident in crate::types::extract_identifiers(
415 &commit_msg,
416 TOKENIZATION.query_min_identifier_length,
417 ) {
418 expansion_concepts.insert(ident);
419 }
420 }
421 }
422 }
423
424 let discovery_ctx = DiscoveryContext {
425 root_dir: root_dir.clone(),
426 changed_files: changed_files.clone(),
427 all_candidates: all_candidate_files,
428 diff_text: diff_text.clone(),
429 expansion_concepts,
430 file_cache,
431 token_corpus: std::sync::OnceLock::new(),
432 };
433
434 let (discovered_files, discovery_attribution) =
435 create_discovery(&config).discover_attributed(&discovery_ctx);
436 let discovered_files: Vec<PathBuf> = discovered_files
437 .into_iter()
438 .map(|p| candidate_files::normalize_path(&p, &root_dir))
439 .collect();
440 let discovery_source: FxHashMap<Arc<str>, &'static str> = discovery_attribution
441 .into_iter()
442 .map(|(path, source)| {
443 let normalized = candidate_files::normalize_path(&path, &root_dir);
444 (Arc::from(normalized.to_string_lossy().as_ref()), source)
445 })
446 .collect();
447
448 drop(discovery_ctx);
449
450 let t_discovery = Instant::now();
451
452 all_fragments.extend(process_files_for_fragments(
453 &discovered_files,
454 &root_dir,
455 &preferred_revs,
456 &mut seen_frag_ids,
457 Some(&mut batch_reader),
458 false,
459 ));
460
461 let t_parse_discovered = Instant::now();
462
463 assign_token_counts(&mut all_fragments);
464
465 let core_ids = identify_core_fragments(&hunks, &all_fragments);
466
467 let mut core_excerpts =
468 crate::excerpt::generate_core_excerpts(&all_fragments, &core_ids, &hunks);
469 assign_excerpt_token_counts(&mut core_excerpts);
470
471 let signature_frags = generate_signature_variants(&all_fragments);
472 let mut sig_frags = signature_frags;
473 assign_token_counts(&mut sig_frags);
474 all_fragments.extend(sig_frags);
475
476 let t_tokenization = Instant::now();
477
478 let seed_weights = compute_seed_weights(&hunks, &core_ids, &all_fragments);
479
480 let discovered_path_set: FxHashSet<Arc<str>> = discovered_files
481 .iter()
482 .map(|p| Arc::from(p.to_string_lossy().as_ref()))
483 .collect();
484
485 let strategy = create_scoring_strategy(&config);
486
487 let scoring_result = strategy.score_and_filter(
488 &all_fragments,
489 &core_ids,
490 &hunks,
491 Some(root_dir.as_path()),
492 Some(&seed_weights),
493 Some(&discovered_path_set),
494 deadline,
495 );
496
497 let needs = crate::utility::needs::needs_from_diff(&all_fragments, &core_ids, &diff_text);
498
499 let t_done = Instant::now();
500 batch_reader.close();
501
502 let graph_build_ms = scoring_result.graph_build_ms;
503 let heavy_latency_ms = HeavyLatencyMs {
504 pre_phase: pre_phase_ms,
505 parse_changed: t_parse_changed.duration_since(t0).as_secs_f64() * 1000.0,
506 universe_walk: t_universe.duration_since(t_parse_changed).as_secs_f64() * 1000.0,
507 discovery: t_discovery.duration_since(t_universe).as_secs_f64() * 1000.0,
508 parse_discovered: t_parse_discovered.duration_since(t_discovery).as_secs_f64() * 1000.0,
509 tokenization: t_tokenization
510 .duration_since(t_parse_discovered)
511 .as_secs_f64()
512 * 1000.0,
513 graph_build: graph_build_ms,
514 scoring: (t_done.duration_since(t_tokenization).as_secs_f64() * 1000.0 - graph_build_ms)
515 .max(0.0),
516 };
517
518 tracing::debug!(
519 "diffctx heavy: pre_phase {:.3}s, parse_changed {:.3}s, universe {:.3}s, discovery {:.3}s, parse_discovered {:.3}s, tokenization {:.3}s, graph_build {:.3}s, scoring {:.3}s",
520 heavy_latency_ms.pre_phase / 1000.0,
521 heavy_latency_ms.parse_changed / 1000.0,
522 heavy_latency_ms.universe_walk / 1000.0,
523 heavy_latency_ms.discovery / 1000.0,
524 heavy_latency_ms.parse_discovered / 1000.0,
525 heavy_latency_ms.tokenization / 1000.0,
526 heavy_latency_ms.graph_build / 1000.0,
527 heavy_latency_ms.scoring / 1000.0,
528 );
529
530 Ok(ScoredState {
531 root_dir,
532 config,
533 all_fragments,
534 core_ids,
535 core_excerpts,
536 scoring_result,
537 needs,
538 discovery_source,
539 changed_files,
540 deleted_files: deleted_display,
541 renamed_files: renamed_display,
542 lockfile_changes: lockfile_display,
543 ignored_changes: ignored_display,
544 policy_excluded_count: policy_excluded,
545 preferred_revs,
546 commit_message,
547 heavy_latency_ms,
548 })
549}
550
551pub struct SelectionOutcome {
552 pub selected: Vec<Fragment>,
553 pub effective_budget: u32,
554 pub selection_iters: usize,
555 pub stopping_certificate: f64,
556 pub select_ms: f64,
557}
558
559pub fn run_selection(
563 state: &ScoredState,
564 budget_tokens: Option<u32>,
565 tau: f64,
566) -> SelectionOutcome {
567 let t_start = Instant::now();
568 let effective_budget = budget_tokens.unwrap_or_else(|| {
569 let core_tokens: u32 = state
570 .all_fragments
571 .iter()
572 .filter(|f| state.core_ids.contains(&f.id))
573 .map(|f| f.token_count.min(BUDGET.core_token_cap_per_fragment))
574 .sum();
575 let auto = (core_tokens as f64 * BUDGET.auto_multiplier) as u32;
576 auto.clamp(BUDGET.auto_min, BUDGET.auto_max)
577 });
578
579 let selection_result = match state.config.objective {
580 crate::mode::ObjectiveMode::BoltzmannModular => {
581 let beta = crate::utility::calibrate_beta(
582 &state.scoring_result.filtered_fragments,
583 &state.core_ids,
584 &state.scoring_result.rel_scores,
585 effective_budget,
586 crate::config::selection::boltzmann().calibration_tolerance,
587 );
588 tracing::debug!("diffctx: boltzmann beta calibrated to {:.6e}", beta);
589 crate::utility::boltzmann_select(
590 &state.scoring_result.filtered_fragments,
591 &state.core_ids,
592 &state.scoring_result.rel_scores,
593 effective_budget,
594 beta,
595 )
596 }
597 crate::mode::ObjectiveMode::Submodular => {
598 let file_importance =
599 crate::utility::compute_file_importance(&state.scoring_result.filtered_fragments);
600 crate::select::lazy_greedy_select(
601 state.scoring_result.filtered_fragments.clone(),
602 &state.core_ids,
603 &state.scoring_result.rel_scores,
604 &state.needs,
605 effective_budget,
606 tau,
607 Some(&file_importance),
608 Some(&state.core_excerpts),
609 state.scoring_result.admissible_files.as_ref(),
610 )
611 }
612 };
613
614 let selection_iters = selection_result.greedy_iters;
615 let stopping_certificate = selection_result.stopping_certificate;
616 let mut selected = selection_result.selected;
617
618 postpass::coherence_post_pass(
619 &mut selected,
620 &state.scoring_result.filtered_fragments,
621 &state.scoring_result.graph,
622 effective_budget,
623 );
624
625 postpass::rescue_nontrivial_context(
626 &mut selected,
627 &state.all_fragments,
628 &state.scoring_result.rel_scores,
629 &state.core_ids,
630 effective_budget,
631 );
632
633 let used: u32 = selected.iter().map(|f| f.token_count).sum();
634 let remaining = effective_budget.saturating_sub(used);
635 let mut batch_reader = match CatFileBatch::new(&state.root_dir) {
636 Ok(r) => Some(r),
637 Err(_) => None,
638 };
639 postpass::ensure_changed_files_represented(
640 &mut selected,
641 &state.all_fragments,
642 &state.changed_files,
643 remaining,
644 &state.root_dir,
645 &state.preferred_revs,
646 batch_reader.as_mut(),
647 &state.core_ids,
648 &state.core_excerpts,
649 );
650 if let Some(mut r) = batch_reader {
651 r.close();
652 }
653
654 crate::provenance::maybe_dump(state, &selected);
655
656 let select_ms = t_start.elapsed().as_secs_f64() * 1000.0;
657 SelectionOutcome {
658 selected,
659 effective_budget,
660 selection_iters,
661 stopping_certificate,
662 select_ms,
663 }
664}
665
666pub fn select_with_params(
674 state: &ScoredState,
675 budget_tokens: Option<u32>,
676 tau: f64,
677 no_content: bool,
678) -> DiffContextOutput {
679 let outcome = run_selection(state, budget_tokens, tau);
680 let selected = outcome.selected;
681 let selection_iters = outcome.selection_iters;
682 let stopping_certificate = outcome.stopping_certificate;
683 let select_ms = outcome.select_ms;
684
685 let total_ms = state.heavy_latency_ms.pre_phase
686 + state.heavy_latency_ms.parse_changed
687 + state.heavy_latency_ms.universe_walk
688 + state.heavy_latency_ms.discovery
689 + state.heavy_latency_ms.parse_discovered
690 + state.heavy_latency_ms.tokenization
691 + state.heavy_latency_ms.graph_build
692 + state.heavy_latency_ms.scoring
693 + select_ms;
694
695 let cap_stats = state.scoring_result.graph.cap_stats.clone();
696 let change = render::ChangeSummary {
697 commit_message: state.commit_message.clone(),
698 changed_files: state
699 .changed_files
700 .iter()
701 .map(|p| {
702 p.strip_prefix(&state.root_dir)
703 .unwrap_or(p)
704 .to_string_lossy()
705 .replace('\\', "/")
706 })
707 .collect(),
708 deleted_files: state.deleted_files.clone(),
709 renamed_files: state.renamed_files.clone(),
710 lockfile_changes: state.lockfile_changes.clone(),
711 ignored_changes: state.ignored_changes.clone(),
712 policy_excluded_count: state.policy_excluded_count,
713 };
714 let mut render_core_ids = state.core_ids.clone();
718 render_core_ids.extend(
719 selected
720 .iter()
721 .filter(|f| f.kind == crate::types::FragmentKind::Excerpt)
722 .map(|f| f.id.clone()),
723 );
724
725 let mut output = render::build_diff_context_output(
726 &state.root_dir,
727 &selected,
728 no_content,
729 &render_core_ids,
730 &state.scoring_result.rel_scores,
731 change,
732 );
733 tracing::debug!(
734 "diffctx selection: selection {:.3}s (incl. post-passes), total {:.3}s",
735 select_ms / 1000.0,
736 total_ms / 1000.0,
737 );
738 output.latency = Some(render::LatencyBreakdown {
739 pre_phase_ms: state.heavy_latency_ms.pre_phase,
740 parse_changed_ms: state.heavy_latency_ms.parse_changed,
741 universe_walk_ms: state.heavy_latency_ms.universe_walk,
742 discovery_ms: state.heavy_latency_ms.discovery,
743 parse_discovered_ms: state.heavy_latency_ms.parse_discovered,
744 tokenization_ms: state.heavy_latency_ms.tokenization,
745 graph_build_ms: state.heavy_latency_ms.graph_build,
746 scoring_selection_ms: state.heavy_latency_ms.graph_build
747 + state.heavy_latency_ms.scoring
748 + select_ms,
749 total_ms,
750 scoring_ms: state.heavy_latency_ms.scoring,
751 selection_ms: select_ms,
752 candidate_count: state.scoring_result.filtered_fragments.len(),
753 edge_count: state.scoring_result.graph.edge_count(),
754 greedy_iters: selection_iters,
755 edges_before_cap: cap_stats.edges_before_cap,
756 edges_dropped_by_cap: cap_stats.edges_dropped_by_cap,
757 nodes_capped: cap_stats.nodes_capped,
758 max_out_edges_per_node: cap_stats.max_out_edges_per_node,
759 ppr_truncated: state.scoring_result.ppr_truncated,
760 stopping_certificate,
761 ppr_forward_pushes: state.scoring_result.ppr_forward_pushes,
762 ppr_backward_pushes: state.scoring_result.ppr_backward_pushes,
763 peak_rss_bytes: crate::peak_rss::peak_rss_bytes(),
764 edge_emissions_by_category: cap_stats
765 .emissions_by_category
766 .iter()
767 .map(|&(category, raw, deduped)| (category.as_str(), raw, deduped))
768 .collect(),
769 });
770 output
771}
772
773pub(crate) fn is_secret_path(path: &Path) -> bool {
782 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
783 return false;
784 };
785 if matches!(
789 name,
790 "id_rsa"
791 | "id_dsa"
792 | "id_ecdsa"
793 | "id_ed25519"
794 | "id_ed25519_sk"
795 | "id_ecdsa_sk"
796 | ".netrc"
797 | "_netrc"
798 | "credentials"
799 | ".npmrc"
800 | ".pypirc"
801 ) {
802 return true;
803 }
804 matches!(
805 path.extension().and_then(|e| e.to_str()),
806 Some("pem" | "key" | "pfx" | "p12" | "keystore" | "jks" | "ppk" | "p8" | "asc")
810 )
811}
812
813fn is_lockfile_path(path: &Path) -> bool {
819 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
820 return false;
821 };
822 matches!(
823 name,
824 "Cargo.lock"
825 | "package-lock.json"
826 | "npm-shrinkwrap.json"
827 | "yarn.lock"
828 | "pnpm-lock.yaml"
829 | "bun.lock"
830 | "bun.lockb"
831 | "deno.lock"
832 | "Pipfile.lock"
833 | "poetry.lock"
834 | "uv.lock"
835 | "pdm.lock"
836 | "composer.lock"
837 | "Gemfile.lock"
838 | "flake.lock"
839 | "go.sum"
840 | "mix.lock"
841 | "packages.lock.json"
842 | "gradle.lockfile"
843 | "Package.resolved"
844 | "cabal.project.freeze"
845 )
846}
847
848pub(crate) fn rel_path_string(root_dir: &Path, path: &Path) -> Option<String> {
849 crate::paths::display_rel(root_dir, path)
850}
851
852fn resolve_ignored_paths(
858 root_dir: &Path,
859 hunks: &[crate::types::DiffHunk],
860) -> rustc_hash::FxHashMap<String, git::IgnoreSource> {
861 let rel_paths: Vec<String> = hunks
862 .iter()
863 .filter_map(|h| rel_path_string(root_dir, Path::new(&*h.path)))
864 .collect();
865 git::find_ignored_paths_with_source(root_dir, &rel_paths)
866}
867
868pub(crate) fn is_ignored_path(
869 root_dir: &Path,
870 path: &Path,
871 ignored_rel_paths: &rustc_hash::FxHashMap<String, git::IgnoreSource>,
872) -> bool {
873 rel_path_string(root_dir, path)
874 .map(|rel| ignored_rel_paths.contains_key(&rel))
875 .unwrap_or(false)
876}
877
878pub fn raw_diff_text(root_dir: &Path, diff_range: Option<&str>, timeout: u64) -> Result<String> {
883 git::set_git_timeout(timeout);
884 let root_dir = resolve_repo_root(root_dir)?;
885 let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
886 let diff_text = git::get_diff_text(&root_dir, resolved.range.as_deref())?;
887 Ok(keep_disclosable_sections(&root_dir, &diff_text))
888}
889
890fn keep_disclosable_sections(root_dir: &Path, diff_text: &str) -> String {
891 let raw: Vec<&str> = diff_text.split_inclusive('\n').collect();
897 let lines: Vec<&str> = raw
898 .iter()
899 .map(|line| line.trim_end_matches('\n').trim_end_matches('\r'))
900 .collect();
901 let sections = split_diff_sections(root_dir, &lines);
902 let rel_paths: Vec<String> = sections
903 .iter()
904 .filter_map(|(path, _)| path.as_deref())
905 .filter_map(|path| rel_path_string(root_dir, path))
906 .collect();
907 let ignored_rel_paths = git::find_ignored_paths_with_source(root_dir, &rel_paths);
908
909 let mut kept: Vec<&str> = Vec::new();
910 for (path, range) in sections {
911 let Some(path) = path else {
915 continue;
916 };
917 if is_secret_path(&path)
918 || is_lockfile_path(&path)
919 || is_ignored_path(root_dir, &path, &ignored_rel_paths)
920 {
921 continue;
922 }
923 kept.extend_from_slice(&raw[range]);
924 }
925 if kept.is_empty() {
926 return String::new();
927 }
928 let mut text = kept.concat();
929 if !text.ends_with('\n') {
930 text.push('\n');
931 }
932 text
933}
934
935type DiffSection = (Option<PathBuf>, std::ops::Range<usize>);
936
937fn split_diff_sections(root_dir: &Path, lines: &[&str]) -> Vec<DiffSection> {
938 let starts: Vec<usize> = lines
939 .iter()
940 .enumerate()
941 .filter(|(_, line)| line.starts_with("diff --git "))
942 .map(|(index, _)| index)
943 .collect();
944 starts
945 .iter()
946 .enumerate()
947 .map(|(nth, &start)| {
948 let end = starts.get(nth + 1).copied().unwrap_or(lines.len());
949 (section_path(root_dir, &lines[start..end]), start..end)
950 })
951 .collect()
952}
953
954fn section_path(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
955 let mut old_path: Option<PathBuf> = None;
956 let mut new_path: Option<PathBuf> = None;
957 for line in section.iter().take_while(|line| !line.starts_with("@@")) {
958 match git::parse_path_line(line, root_dir) {
959 ("new", path) => new_path = path,
960 ("old", path) => old_path = path,
961 _ => {}
962 }
963 }
964 new_path
965 .or(old_path)
966 .or_else(|| pathless_section(root_dir, section))
967}
968
969fn pathless_section(root_dir: &Path, section: &[&str]) -> Option<PathBuf> {
975 if let Some(quoted) = section
976 .iter()
977 .find_map(|line| line.strip_prefix("rename to "))
978 {
979 return git::resolve_in_repo(root_dir, &git::unquote_c_style(quoted.trim()));
980 }
981 let rest = section.first()?.strip_prefix("diff --git ")?;
982 let rel_path = rest.get("a/".len()..rest.find(" b/")?)?;
983 if rest != format!("a/{rel_path} b/{rel_path}") {
984 return None;
985 }
986 git::resolve_in_repo(root_dir, rel_path)
987}
988
989fn resolve_repo_root(root_dir: &Path) -> Result<PathBuf> {
990 let root_dir = root_dir.canonicalize().unwrap_or_else(|e| {
991 tracing::debug!("canonicalize failed for '{}': {}", root_dir.display(), e);
992 root_dir.to_path_buf()
993 });
994 if !git::is_git_repo(&root_dir) {
995 anyhow::bail!("'{}' is not a git repository", root_dir.display());
996 }
997 Ok(git::find_toplevel(&root_dir).unwrap_or(root_dir))
998}
999
1000fn build_diff_context_full(
1001 root_dir: &Path,
1002 diff_range: Option<&str>,
1003 no_content: bool,
1004 timeout: u64,
1005) -> Result<DiffContextOutput> {
1006 git::set_git_timeout(timeout);
1009 let root_dir = resolve_repo_root(root_dir)?;
1010 let resolved = git::resolve_duration_range(&root_dir, diff_range)?;
1011 let diff_range = resolved.range.as_deref();
1012 let mut hunks = git::parse_diff(&root_dir, diff_range)?;
1013 hunks.retain(|h| !is_secret_path(Path::new(&*h.path)));
1014 if hunks.is_empty() {
1015 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1016 let mut output = empty_output(&root_dir);
1017 output.deleted_files = deleted;
1018 output.renamed_files = renamed;
1019 return Ok(output);
1020 }
1021 let ignored_rel_paths = resolve_ignored_paths(&root_dir, &hunks);
1022 hunks.retain(|h| !is_ignored_path(&root_dir, Path::new(&*h.path), &ignored_rel_paths));
1023 if hunks.is_empty() {
1024 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1025 let mut output = empty_output(&root_dir);
1026 output.deleted_files = deleted;
1027 output.renamed_files = renamed;
1028 return Ok(output);
1029 }
1030 let mut changed_files = git::get_changed_files(&root_dir, diff_range)?;
1031 changed_files
1032 .retain(|f| !is_secret_path(f) && !is_ignored_path(&root_dir, f, &ignored_rel_paths));
1033 if changed_files.is_empty() {
1034 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1035 let mut output = empty_output(&root_dir);
1036 output.deleted_files = deleted;
1037 output.renamed_files = renamed;
1038 return Ok(output);
1039 }
1040 let (base_rev, head_rev) = diff_range
1041 .map(git::split_diff_range)
1042 .unwrap_or((None, None));
1043 let preferred_revs = build_preferred_revs(base_rev.as_deref(), head_rev.as_deref());
1044 let mut seen_frag_ids: FxHashSet<FragmentId> = FxHashSet::default();
1045 let mut batch_reader = CatFileBatch::new(&root_dir)?;
1046 let mut all_fragments = process_files_for_fragments(
1047 &changed_files,
1048 &root_dir,
1049 &preferred_revs,
1050 &mut seen_frag_ids,
1051 Some(&mut batch_reader),
1052 true,
1053 );
1054 assign_token_counts(&mut all_fragments);
1055 let mut sig_frags = generate_signature_variants(&all_fragments);
1056 assign_token_counts(&mut sig_frags);
1057 all_fragments.extend(sig_frags);
1058 changed_files.sort();
1059 let core_ids = identify_core_fragments(&hunks, &all_fragments);
1060 let selected = select_full_mode(&all_fragments, &changed_files);
1061 batch_reader.close();
1062 let commit_message = head_rev
1063 .as_deref()
1064 .and_then(|h| git::get_commit_message(&root_dir, h).ok())
1065 .and_then(|m| {
1066 m.lines()
1067 .map(str::trim)
1068 .find(|l| !l.is_empty())
1069 .map(str::to_string)
1070 });
1071 let mut deleted_display: Vec<String> = git::get_deleted_files(&root_dir, diff_range)
1072 .map(|set| {
1073 set.iter()
1074 .map(|p| {
1075 p.strip_prefix(&root_dir)
1076 .unwrap_or(p)
1077 .to_string_lossy()
1078 .replace('\\', "/")
1079 })
1080 .collect()
1081 })
1082 .unwrap_or_default();
1083 deleted_display.sort();
1084 let change = render::ChangeSummary {
1085 commit_message,
1086 changed_files: changed_files
1087 .iter()
1088 .map(|p| {
1089 p.strip_prefix(&root_dir)
1090 .unwrap_or(p)
1091 .to_string_lossy()
1092 .replace('\\', "/")
1093 })
1094 .collect(),
1095 deleted_files: deleted_display,
1096 renamed_files: git::get_rename_pairs(&root_dir, diff_range).unwrap_or_default(),
1097 lockfile_changes: Vec::new(),
1100 ignored_changes: Vec::new(),
1101 policy_excluded_count: 0,
1102 };
1103 Ok(render::build_diff_context_output(
1104 &root_dir,
1105 &selected,
1106 no_content,
1107 &core_ids,
1108 &FxHashMap::default(),
1109 change,
1110 ))
1111}
1112
1113fn deletion_rename_displays(
1114 root_dir: &Path,
1115 diff_range: Option<&str>,
1116) -> (Vec<String>, Vec<(String, String)>) {
1117 let mut deleted: Vec<String> = git::get_deleted_files(root_dir, diff_range)
1118 .map(|set| {
1119 set.iter()
1120 .map(|p| {
1121 p.strip_prefix(root_dir)
1122 .unwrap_or(p)
1123 .to_string_lossy()
1124 .replace('\\', "/")
1125 })
1126 .collect()
1127 })
1128 .unwrap_or_default();
1129 deleted.sort();
1130 let renamed = git::get_rename_pairs(root_dir, diff_range).unwrap_or_default();
1131 (deleted, renamed)
1132}
1133
1134fn empty_scored_state_with_changes(root_dir: PathBuf, diff_range: Option<&str>) -> ScoredState {
1135 let (deleted, renamed) = deletion_rename_displays(&root_dir, diff_range);
1136 let mut state = empty_scored_state(root_dir);
1137 state.deleted_files = deleted;
1138 state.renamed_files = renamed;
1139 state
1140}
1141
1142fn empty_scored_state(root_dir: PathBuf) -> ScoredState {
1143 let config = PipelineConfig::from_mode(ScoringMode::Ego);
1144 ScoredState {
1145 root_dir,
1146 config,
1147 all_fragments: Vec::new(),
1148 core_ids: FxHashSet::default(),
1149 core_excerpts: FxHashMap::default(),
1150 discovery_source: FxHashMap::default(),
1151 lockfile_changes: Vec::new(),
1152 ignored_changes: Vec::new(),
1153 policy_excluded_count: 0,
1154 scoring_result: ScoringResult {
1155 admissible_files: None,
1156 rel_scores: FxHashMap::default(),
1157 filtered_fragments: Vec::new(),
1158 graph: crate::graph::Graph::new(),
1159 graph_build_ms: 0.0,
1160 ppr_truncated: false,
1161 ppr_forward_pushes: 0,
1162 ppr_backward_pushes: 0,
1163 },
1164 needs: Vec::new(),
1165 changed_files: Vec::new(),
1166 deleted_files: Vec::new(),
1167 renamed_files: Vec::new(),
1168 preferred_revs: Vec::new(),
1169 commit_message: None,
1170 heavy_latency_ms: HeavyLatencyMs::default(),
1171 }
1172}
1173
1174fn empty_output(root_dir: &Path) -> DiffContextOutput {
1175 let resolved = root_dir
1176 .canonicalize()
1177 .unwrap_or_else(|_| root_dir.to_path_buf());
1178 let name = resolved
1179 .file_name()
1180 .map(|n| n.to_string_lossy().to_string())
1181 .unwrap_or_else(|| resolved.to_string_lossy().to_string());
1182 DiffContextOutput {
1183 name,
1184 output_type: "diff_context".to_string(),
1185 commit_message: None,
1186 changed_files: Vec::new(),
1187 deleted_files: Vec::new(),
1188 renamed_files: Vec::new(),
1189 lockfile_changes: Vec::new(),
1190 ignored_changes: Vec::new(),
1191 policy_excluded_count: 0,
1192 fragment_count: 0,
1193 fragments: Vec::new(),
1194 latency: None,
1195 }
1196}
1197
1198pub(crate) fn empty_output_from_state(state: &ScoredState) -> DiffContextOutput {
1203 let mut output = empty_output(&state.root_dir);
1204 output.commit_message = state.commit_message.clone();
1205 output.deleted_files = state.deleted_files.clone();
1206 output.renamed_files = state.renamed_files.clone();
1207 output.lockfile_changes = state.lockfile_changes.clone();
1208 output.ignored_changes = state.ignored_changes.clone();
1209 output.policy_excluded_count = state.policy_excluded_count;
1210 output
1211}
1212
1213fn build_preferred_revs(base_rev: Option<&str>, head_rev: Option<&str>) -> Vec<String> {
1214 let mut revs = Vec::new();
1215 if let Some(h) = head_rev {
1216 revs.push(h.to_string());
1217 }
1218 if let Some(b) = base_rev {
1219 if Some(b) != head_rev {
1220 revs.push(b.to_string());
1221 }
1222 }
1223 revs
1224}
1225
1226fn create_discovery(config: &PipelineConfig) -> Box<dyn DiscoveryStrategy> {
1227 match config.discovery {
1228 DiscoveryKind::Ensemble => Box::new(EnsembleDiscovery::new(vec![
1229 Box::new(DefaultDiscovery),
1230 Box::new(TestFileDiscovery),
1231 Box::new(BM25Discovery::new(config.bm25_top_k)),
1232 ])),
1233 DiscoveryKind::Default => Box::new(DefaultDiscovery),
1234 }
1235}
1236
1237fn build_file_cache(candidate_files: &[PathBuf]) -> FxHashMap<PathBuf, String> {
1238 let mut sorted = candidate_files.to_vec();
1243 sorted.sort();
1244 let mut cache: FxHashMap<PathBuf, String> = FxHashMap::default();
1245 let mut cache_bytes = 0usize;
1246 for path in sorted {
1247 if cache_bytes > GRAPH_FILTERING.max_cache_bytes {
1248 break;
1249 }
1250 let Ok(meta) = path.metadata() else { continue };
1251 if meta.len() as usize > LIMITS.max_file_size {
1252 continue;
1253 }
1254 if let Ok(content) = std::fs::read_to_string(&path) {
1255 cache_bytes += content.len();
1256 cache.insert(path, content);
1257 }
1258 }
1259 cache
1260}
1261
1262fn assign_token_counts(fragments: &mut [Fragment]) {
1263 fragments.par_iter_mut().for_each(|frag| {
1264 if frag.token_count == 0 {
1265 frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1266 }
1267 });
1268}
1269
1270fn assign_excerpt_token_counts(excerpts: &mut FxHashMap<FragmentId, Fragment>) {
1271 for frag in excerpts.values_mut() {
1272 if frag.token_count == 0 {
1273 frag.token_count = count_tokens(&frag.content) + LIMITS.overhead_per_fragment;
1274 }
1275 }
1276}
1277
1278fn select_full_mode(all_fragments: &[Fragment], changed_files: &[PathBuf]) -> Vec<Fragment> {
1279 let changed_paths: FxHashSet<String> = changed_files
1280 .iter()
1281 .map(|p| p.to_string_lossy().to_string())
1282 .collect();
1283 let mut selected: Vec<Fragment> = all_fragments
1284 .iter()
1285 .filter(|f| changed_paths.contains(f.path()))
1286 .cloned()
1287 .collect();
1288 selected.sort_by(|a, b| {
1289 a.path()
1290 .cmp(b.path())
1291 .then(a.start_line().cmp(&b.start_line()))
1292 });
1293 selected
1294}
1295
1296#[cfg(test)]
1297mod tests {
1298 use super::*;
1299
1300 #[test]
1307 fn a_raw_diff_section_escaping_the_root_resolves_to_nothing() {
1308 let tmp = tempfile::TempDir::new().expect("tempdir");
1309 let base = tmp.path().canonicalize().expect("canonical tempdir");
1310 let root = base.join("repo");
1311 std::fs::create_dir_all(&root).expect("mkdir repo");
1312 std::fs::write(base.join("outside.py"), "secret = 1\n").expect("write outside");
1313 std::fs::write(root.join("inside.py"), "x = 1\n").expect("write inside");
1314
1315 for rel in ["../outside.py", "../missing.py", "sub/../../outside.py"] {
1316 let header = format!("diff --git a/{rel} b/{rel}");
1317 assert!(
1318 section_path(&root, &[header.as_str()]).is_none(),
1319 "escaping section accepted: {rel}"
1320 );
1321 }
1322
1323 let header = "diff --git a/inside.py b/inside.py";
1324 assert!(
1325 section_path(&root, &[header]).is_some_and(|p| p.ends_with("inside.py")),
1326 "an in-repo section was dropped"
1327 );
1328 }
1329}
1330
1331#[cfg(test)]
1332mod secret_path_tests {
1333 use super::is_secret_path;
1334 use std::path::Path;
1335
1336 fn secret(p: &str) -> bool {
1337 is_secret_path(Path::new(p))
1338 }
1339
1340 #[test]
1346 fn private_key_and_credential_shapes_are_excluded() {
1347 for path in [
1348 "home/.ssh/id_rsa",
1349 "home/.ssh/id_ed25519",
1350 "home/.ssh/id_ed25519_sk",
1351 "home/.ssh/id_ecdsa_sk",
1352 "certs/server.pem",
1353 "certs/server.key",
1354 "certs/bundle.pfx",
1355 "certs/bundle.p12",
1356 "android/release.keystore",
1357 "android/release.jks",
1358 "windows/deploy.ppk",
1359 "apple/AuthKey_ABC123.p8",
1360 "gpg/private.asc",
1361 "home/.netrc",
1362 "home/_netrc",
1363 "aws/credentials",
1364 "home/.npmrc",
1365 "home/.pypirc",
1366 ] {
1367 assert!(secret(path), "not excluded: {path}");
1368 }
1369 }
1370
1371 #[test]
1374 fn public_material_is_not_excluded() {
1375 for path in [
1376 "home/.ssh/id_rsa.pub",
1377 "home/.ssh/id_ed25519.pub",
1378 "home/.ssh/authorized_keys",
1379 "certs/server.crt",
1380 ] {
1381 assert!(!secret(path), "wrongly excluded: {path}");
1382 }
1383 }
1384
1385 #[test]
1389 fn env_files_remain_visible_by_design() {
1390 assert!(!secret(".env"));
1391 assert!(!secret("config/.env.production"));
1392 }
1393
1394 #[test]
1397 fn ordinary_files_are_untouched() {
1398 for path in [
1399 "src/keyboard.rs",
1400 "src/credentials_form.tsx",
1401 "docs/pemphigus.md",
1402 ] {
1403 assert!(!secret(path), "wrongly excluded: {path}");
1404 }
1405 }
1406}