1use std::path::Path;
2
3use rustc_hash::{FxHashMap, FxHashSet};
4use serde::Serialize;
5
6use crate::pipeline::{ScoredState, SelectionOutcome};
7use crate::provenance::{incoming_attribution, seed_hops};
8use crate::types::{Fragment, FragmentId};
9
10pub const LOCATE_SCHEMA: &str = "diffctx.locate.v1";
11
12#[derive(Serialize)]
13pub struct LocateOutput {
14 pub schema: &'static str,
15 pub name: String,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub commit_message: Option<String>,
18 #[serde(skip_serializing_if = "Vec::is_empty")]
19 pub changed_files: Vec<String>,
20 #[serde(skip_serializing_if = "Vec::is_empty")]
21 pub deleted_files: Vec<String>,
22 #[serde(skip_serializing_if = "Vec::is_empty")]
23 pub renamed_files: Vec<RenameEntry>,
24 #[serde(skip_serializing_if = "Vec::is_empty")]
25 pub lockfile_changes: Vec<String>,
26 #[serde(skip_serializing_if = "Vec::is_empty", default)]
27 pub ignored_changes: Vec<String>,
28 #[serde(skip_serializing_if = "crate::render::is_zero", default)]
29 pub policy_excluded_count: usize,
30 pub budget_tokens: u32,
31 pub summary: Summary,
35 pub item_count: usize,
36 pub items: Vec<LocateItem>,
37 #[serde(skip_serializing_if = "Coverage::is_clean")]
43 pub coverage: Coverage,
44 #[serde(skip_serializing_if = "Vec::is_empty")]
46 pub overflow: Vec<OverflowItem>,
47 #[serde(skip_serializing_if = "crate::render::is_zero")]
49 pub overflow_count: usize,
50}
51
52#[allow(clippy::trivially_copy_pass_by_ref)]
54pub const MAX_OVERFLOW_ITEMS: usize = 50;
58
59#[derive(Serialize, Default)]
60pub struct Coverage {
61 #[serde(skip_serializing_if = "Vec::is_empty")]
65 pub unparsed_files: Vec<String>,
66 #[serde(skip_serializing_if = "Vec::is_empty")]
70 pub zero_edge_files: Vec<String>,
71 #[serde(skip_serializing_if = "std::ops::Not::not")]
75 pub ppr_truncated: bool,
76 #[serde(skip_serializing_if = "crate::render::is_zero")]
85 pub next_up: usize,
86 pub confidence: f64,
91}
92
93impl Coverage {
94 pub fn is_clean(&self) -> bool {
98 self.unparsed_files.is_empty()
99 && self.zero_edge_files.is_empty()
100 && !self.ppr_truncated
101 && self.next_up == 0
102 }
103}
104
105#[derive(Serialize)]
106pub struct OverflowItem {
107 pub path: String,
108 pub lines: String,
109 pub score: f64,
110 pub tokens: u32,
111 pub why: String,
114}
115
116#[derive(Serialize)]
117pub struct Summary {
118 pub files: usize,
119 pub changed: usize,
120 pub context: usize,
121 pub tests: usize,
122}
123
124#[derive(Serialize)]
125pub struct RenameEntry {
126 pub from: String,
127 pub to: String,
128}
129
130#[derive(Serialize)]
133pub struct LocateItem {
134 pub path: String,
135 pub lines: String,
136 pub kind: String,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub symbol: Option<String>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub role: Option<&'static str>,
141 #[serde(skip_serializing_if = "Option::is_none")]
144 pub group: Option<&'static str>,
145 pub score: f64,
146 pub tokens: u32,
147 pub reasons: Vec<Reason>,
148}
149
150fn is_test_path(path: &str) -> bool {
162 crate::testfiles::is_test_path(Path::new(path))
163}
164
165const CONFIG_EXTENSIONS: &[&str] = &[
166 "yaml",
167 "yml",
168 "json",
169 "toml",
170 "ini",
171 "cfg",
172 "conf",
173 "env",
174 "properties",
175];
176
177fn group_of(path: &str, kind: crate::types::FragmentKind) -> Option<&'static str> {
178 use crate::types::FragmentKind as K;
179 if is_test_path(path) {
180 return Some("test");
181 }
182 if matches!(
183 kind,
184 K::Struct
185 | K::Enum
186 | K::Interface
187 | K::Type
188 | K::Record
189 | K::StructSignature
190 | K::ClassSignature
191 ) {
192 return Some("type");
193 }
194 let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
195 if CONFIG_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
196 return Some("config");
197 }
198 None
199}
200
201#[derive(Serialize)]
202#[serde(tag = "type", rename_all = "snake_case")]
203pub enum Reason {
204 Changed,
206 Edge {
208 category: String,
209 from: String,
210 mass: f64,
211 },
212 Proximity { seed_hops: u32 },
214 PostPass,
217}
218
219fn rel_path(state: &ScoredState, path: &str) -> String {
225 crate::paths::display_rel(&state.root_dir, Path::new(path))
226 .unwrap_or_else(|| crate::paths::to_posix_display(std::borrow::Cow::Borrowed(path)))
227}
228
229fn carries_change(
233 state: &ScoredState,
234 frag: &Fragment,
235 core_locs: &rustc_hash::FxHashSet<(std::sync::Arc<str>, u32)>,
236) -> bool {
237 state.core_ids.contains(&frag.id)
238 || frag.kind == crate::types::FragmentKind::Excerpt
239 || (frag.kind.is_signature()
240 && core_locs.contains(&(frag.id.path.clone(), frag.id.start_line)))
241}
242
243fn reasons_for(
244 state: &ScoredState,
245 frag: &Fragment,
246 core_locs: &rustc_hash::FxHashSet<(std::sync::Arc<str>, u32)>,
247 hops: Option<u32>,
248 attribution: Option<&Vec<(String, String, f64)>>,
249) -> Vec<Reason> {
250 if carries_change(state, frag, core_locs) {
251 return vec![Reason::Changed];
252 }
253 let mut reasons: Vec<Reason> = Vec::new();
254 if let Some(per_cat) = attribution {
255 if let Some((category, from, mass)) = per_cat.first() {
258 reasons.push(Reason::Edge {
259 category: category.clone(),
260 from: rel_path(state, from),
261 mass: (mass * 1e3).round() / 1e3,
262 });
263 }
264 }
265 if let Some(h) = hops {
266 reasons.push(Reason::Proximity { seed_hops: h });
267 }
268 if reasons.is_empty() {
269 reasons.push(Reason::PostPass);
270 }
271 reasons
272}
273
274fn is_structural(kind: crate::types::FragmentKind) -> bool {
281 use crate::types::FragmentKind as K;
282 !matches!(kind, K::Chunk | K::Excerpt)
283}
284
285fn build_coverage(
286 state: &ScoredState,
287 outcome: &SelectionOutcome,
288 next_up: usize,
289 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
290) -> Coverage {
291 let graph = &state.scoring_result.graph;
292 let changed: Vec<String> = state
293 .changed_files
294 .iter()
295 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
296 .collect();
297
298 let mut by_file: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
303 for f in &state.all_fragments {
304 by_file
305 .entry(rel_path(state, f.id.path.as_ref()))
306 .or_default()
307 .push(f);
308 }
309
310 let mut unparsed: Vec<String> = Vec::new();
311 let mut zero_edge: Vec<String> = Vec::new();
312 let empty: Vec<&Fragment> = Vec::new();
313 for file in &changed {
314 let frags = by_file.get(file).unwrap_or(&empty);
315 if frags.is_empty() {
316 continue;
320 }
321 let parseable = crate::languages::get_language_for_file(file).is_some();
325 if parseable && !frags.iter().any(|f| is_structural(f.kind)) {
326 unparsed.push(file.clone());
327 }
328 let linked = frags.iter().any(|f| {
329 if attribution.contains_key(&f.id) {
330 return true;
331 }
332 let mut has_out = false;
333 graph.for_each_forward_neighbor(&f.id, |_, _| has_out = true);
334 has_out
335 });
336 if !linked {
337 zero_edge.push(file.clone());
338 }
339 }
340 unparsed.sort();
341 unparsed.dedup();
342 zero_edge.sort();
343 zero_edge.dedup();
344
345 let n_changed = changed.len().max(1) as f64;
346 let parsed_share = 1.0 - unparsed.len() as f64 / n_changed;
347 let linked_share = 1.0 - zero_edge.len() as f64 / n_changed;
348 let selected_context = outcome
352 .selected
353 .iter()
354 .filter(|f| !state.core_ids.contains(&f.id))
355 .count();
356 let fit_share = if selected_context + next_up == 0 {
361 1.0
362 } else {
363 selected_context as f64 / (selected_context + next_up) as f64
364 };
365 let truncated = state.scoring_result.ppr_truncated;
366 let raw = parsed_share * linked_share * fit_share - if truncated { 0.1 } else { 0.0 };
367
368 Coverage {
369 unparsed_files: unparsed,
370 zero_edge_files: zero_edge,
371 ppr_truncated: truncated,
372 next_up,
373 confidence: (raw.clamp(0.0, 1.0) * 1e2).round() / 1e2,
374 }
375}
376
377fn build_overflow(
385 state: &ScoredState,
386 outcome: &SelectionOutcome,
387 hops: &FxHashMap<FragmentId, u32>,
388 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
389) -> (Vec<OverflowItem>, usize, usize) {
390 let selected: FxHashSet<&FragmentId> = outcome.selected.iter().map(|f| &f.id).collect();
391 let rel = &state.scoring_result.rel_scores;
392 let mut skipped: Vec<&Fragment> = state
393 .scoring_result
394 .filtered_fragments
395 .iter()
396 .filter(|f| !selected.contains(&f.id) && !state.core_ids.contains(&f.id))
397 .collect();
398 skipped.sort_by(|a, b| {
399 let sa = rel.get(&a.id).copied().unwrap_or(0.0);
400 let sb = rel.get(&b.id).copied().unwrap_or(0.0);
401 sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
402 });
403 let total = skipped.len();
404 let spent: u32 = outcome.selected.iter().map(|f| f.token_count).sum();
409 let headroom = outcome.effective_budget.saturating_sub(spent);
410 let smallest_skipped = skipped.iter().map(|f| f.token_count).min().unwrap_or(0);
411 let budget_bound = !skipped.is_empty() && headroom < smallest_skipped;
412 let next_up = if budget_bound {
413 let extra = outcome.effective_budget / 4;
417 let mut spare = headroom + extra;
418 let mut n = 0;
419 for f in &skipped {
420 if f.token_count > spare {
421 break;
422 }
423 spare -= f.token_count;
424 n += 1;
425 }
426 n
427 } else {
428 0
429 };
430 let items = skipped
431 .into_iter()
432 .take(MAX_OVERFLOW_ITEMS)
433 .map(|frag| OverflowItem {
434 path: rel_path(state, frag.id.path.as_ref()),
435 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
436 score: rel
437 .get(&frag.id)
438 .map(|s| (s * 1e4).round() / 1e4)
439 .unwrap_or(0.0),
440 tokens: frag.token_count,
441 why: overflow_why(
442 state,
443 hops.get(&frag.id).copied(),
444 attribution.get(&frag.id),
445 ),
446 })
447 .collect();
448 (items, total, next_up)
449}
450
451fn overflow_why(
452 state: &ScoredState,
453 hops: Option<u32>,
454 attribution: Option<&Vec<(String, String, f64)>>,
455) -> String {
456 if let Some((category, from, _)) = attribution.and_then(|rows| rows.first()) {
457 return format!("{category} from {}", rel_path(state, from));
458 }
459 match hops {
460 Some(h) => format!("{h} hop(s) from a change"),
461 None => "post-pass candidate".to_string(),
462 }
463}
464
465pub fn build_locate(state: &ScoredState, outcome: &SelectionOutcome) -> LocateOutput {
469 let rel = &state.scoring_result.rel_scores;
470 let hops = seed_hops(state);
471 let attribution = incoming_attribution(state);
472 let (overflow, overflow_count, next_up) = build_overflow(state, outcome, &hops, &attribution);
473
474 let core_locs = crate::render::core_substitute_locs(&state.core_ids);
475 let items: Vec<LocateItem> = outcome
476 .selected
477 .iter()
478 .map(|frag| {
479 let is_changed = carries_change(state, frag, &core_locs);
480 let path = rel_path(state, frag.id.path.as_ref());
481 let group = group_of(&path, frag.kind);
482 LocateItem {
483 path,
484 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
485 kind: format!("{:?}", frag.kind).to_lowercase(),
486 symbol: frag.symbol_name.clone(),
487 role: if is_changed { Some("changed") } else { None },
488 group,
489 score: rel
490 .get(&frag.id)
491 .map(|s| (s * 1e4).round() / 1e4)
492 .unwrap_or(0.0),
493 tokens: frag.token_count,
494 reasons: reasons_for(
495 state,
496 frag,
497 &core_locs,
498 hops.get(&frag.id).copied(),
499 attribution.get(&frag.id),
500 ),
501 }
502 })
503 .collect();
504
505 LocateOutput {
506 schema: LOCATE_SCHEMA,
507 name: state
508 .root_dir
509 .file_name()
510 .map(|n| n.to_string_lossy().to_string())
511 .unwrap_or_else(|| state.root_dir.to_string_lossy().to_string()),
512 commit_message: state.commit_message.clone(),
513 changed_files: state
514 .changed_files
515 .iter()
516 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
517 .collect(),
518 deleted_files: state.deleted_files.clone(),
519 renamed_files: state
520 .renamed_files
521 .iter()
522 .map(|(from, to)| RenameEntry {
523 from: from.clone(),
524 to: to.clone(),
525 })
526 .collect(),
527 lockfile_changes: state.lockfile_changes.clone(),
528 ignored_changes: state.ignored_changes.clone(),
529 policy_excluded_count: state.policy_excluded_count,
530 budget_tokens: outcome.effective_budget,
531 summary: Summary {
532 files: items
533 .iter()
534 .map(|i| i.path.as_str())
535 .collect::<std::collections::BTreeSet<_>>()
536 .len(),
537 changed: items.iter().filter(|i| i.role == Some("changed")).count(),
538 context: items.iter().filter(|i| i.role.is_none()).count(),
539 tests: items.iter().filter(|i| i.group == Some("test")).count(),
540 },
541 item_count: items.len(),
542 items,
543 coverage: build_coverage(state, outcome, next_up, &attribution),
544 overflow,
545 overflow_count,
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::types::FragmentKind;
553
554 #[test]
561 fn the_grouping_uses_the_shared_test_classifier() {
562 for path in [
563 "src/main/java/com/example/FooTest.java",
564 "src/main/scala/AuthSpec.scala",
565 "src/XMLTest.java",
566 "ui/widget-spec.js",
567 "src/tests.rs",
568 ] {
569 assert_eq!(
570 group_of(path, FragmentKind::Function),
571 Some("test"),
572 "not grouped as a test: {path}"
573 );
574 }
575 }
576
577 #[test]
581 fn a_testing_directory_is_not_itself_a_test() {
582 assert_eq!(
583 group_of("src/testing/helpers.go", FragmentKind::Function),
584 None
585 );
586 }
587
588 #[test]
592 fn a_type_in_a_test_file_groups_as_test() {
593 assert_eq!(
594 group_of("tests/fixtures.rs", FragmentKind::Struct),
595 Some("test")
596 );
597 assert_eq!(group_of("src/model.rs", FragmentKind::Struct), Some("type"));
598 }
599}