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 reasons_for(
230 state: &ScoredState,
231 frag: &Fragment,
232 hops: Option<u32>,
233 attribution: Option<&Vec<(String, String, f64)>>,
234) -> Vec<Reason> {
235 if state.core_ids.contains(&frag.id) || frag.kind == crate::types::FragmentKind::Excerpt {
236 return vec![Reason::Changed];
237 }
238 let mut reasons: Vec<Reason> = Vec::new();
239 if let Some(per_cat) = attribution {
240 if let Some((category, from, mass)) = per_cat.first() {
243 reasons.push(Reason::Edge {
244 category: category.clone(),
245 from: rel_path(state, from),
246 mass: (mass * 1e3).round() / 1e3,
247 });
248 }
249 }
250 if let Some(h) = hops {
251 reasons.push(Reason::Proximity { seed_hops: h });
252 }
253 if reasons.is_empty() {
254 reasons.push(Reason::PostPass);
255 }
256 reasons
257}
258
259fn is_structural(kind: crate::types::FragmentKind) -> bool {
266 use crate::types::FragmentKind as K;
267 !matches!(kind, K::Chunk | K::Excerpt)
268}
269
270fn build_coverage(
271 state: &ScoredState,
272 outcome: &SelectionOutcome,
273 next_up: usize,
274 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
275) -> Coverage {
276 let graph = &state.scoring_result.graph;
277 let changed: Vec<String> = state
278 .changed_files
279 .iter()
280 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
281 .collect();
282
283 let mut by_file: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
288 for f in &state.all_fragments {
289 by_file
290 .entry(rel_path(state, f.id.path.as_ref()))
291 .or_default()
292 .push(f);
293 }
294
295 let mut unparsed: Vec<String> = Vec::new();
296 let mut zero_edge: Vec<String> = Vec::new();
297 let empty: Vec<&Fragment> = Vec::new();
298 for file in &changed {
299 let frags = by_file.get(file).unwrap_or(&empty);
300 if frags.is_empty() {
301 continue;
305 }
306 let parseable = crate::languages::get_language_for_file(file).is_some();
310 if parseable && !frags.iter().any(|f| is_structural(f.kind)) {
311 unparsed.push(file.clone());
312 }
313 let linked = frags.iter().any(|f| {
314 if attribution.contains_key(&f.id) {
315 return true;
316 }
317 let mut has_out = false;
318 graph.for_each_forward_neighbor(&f.id, |_, _| has_out = true);
319 has_out
320 });
321 if !linked {
322 zero_edge.push(file.clone());
323 }
324 }
325 unparsed.sort();
326 unparsed.dedup();
327 zero_edge.sort();
328 zero_edge.dedup();
329
330 let n_changed = changed.len().max(1) as f64;
331 let parsed_share = 1.0 - unparsed.len() as f64 / n_changed;
332 let linked_share = 1.0 - zero_edge.len() as f64 / n_changed;
333 let selected_context = outcome
337 .selected
338 .iter()
339 .filter(|f| !state.core_ids.contains(&f.id))
340 .count();
341 let fit_share = if selected_context + next_up == 0 {
346 1.0
347 } else {
348 selected_context as f64 / (selected_context + next_up) as f64
349 };
350 let truncated = state.scoring_result.ppr_truncated;
351 let raw = parsed_share * linked_share * fit_share - if truncated { 0.1 } else { 0.0 };
352
353 Coverage {
354 unparsed_files: unparsed,
355 zero_edge_files: zero_edge,
356 ppr_truncated: truncated,
357 next_up,
358 confidence: (raw.clamp(0.0, 1.0) * 1e2).round() / 1e2,
359 }
360}
361
362fn build_overflow(
370 state: &ScoredState,
371 outcome: &SelectionOutcome,
372 hops: &FxHashMap<FragmentId, u32>,
373 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
374) -> (Vec<OverflowItem>, usize, usize) {
375 let selected: FxHashSet<&FragmentId> = outcome.selected.iter().map(|f| &f.id).collect();
376 let rel = &state.scoring_result.rel_scores;
377 let mut skipped: Vec<&Fragment> = state
378 .scoring_result
379 .filtered_fragments
380 .iter()
381 .filter(|f| !selected.contains(&f.id) && !state.core_ids.contains(&f.id))
382 .collect();
383 skipped.sort_by(|a, b| {
384 let sa = rel.get(&a.id).copied().unwrap_or(0.0);
385 let sb = rel.get(&b.id).copied().unwrap_or(0.0);
386 sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
387 });
388 let total = skipped.len();
389 let spent: u32 = outcome.selected.iter().map(|f| f.token_count).sum();
394 let headroom = outcome.effective_budget.saturating_sub(spent);
395 let smallest_skipped = skipped.iter().map(|f| f.token_count).min().unwrap_or(0);
396 let budget_bound = !skipped.is_empty() && headroom < smallest_skipped;
397 let next_up = if budget_bound {
398 let extra = outcome.effective_budget / 4;
402 let mut spare = headroom + extra;
403 let mut n = 0;
404 for f in &skipped {
405 if f.token_count > spare {
406 break;
407 }
408 spare -= f.token_count;
409 n += 1;
410 }
411 n
412 } else {
413 0
414 };
415 let items = skipped
416 .into_iter()
417 .take(MAX_OVERFLOW_ITEMS)
418 .map(|frag| OverflowItem {
419 path: rel_path(state, frag.id.path.as_ref()),
420 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
421 score: rel
422 .get(&frag.id)
423 .map(|s| (s * 1e4).round() / 1e4)
424 .unwrap_or(0.0),
425 tokens: frag.token_count,
426 why: overflow_why(
427 state,
428 hops.get(&frag.id).copied(),
429 attribution.get(&frag.id),
430 ),
431 })
432 .collect();
433 (items, total, next_up)
434}
435
436fn overflow_why(
437 state: &ScoredState,
438 hops: Option<u32>,
439 attribution: Option<&Vec<(String, String, f64)>>,
440) -> String {
441 if let Some((category, from, _)) = attribution.and_then(|rows| rows.first()) {
442 return format!("{category} from {}", rel_path(state, from));
443 }
444 match hops {
445 Some(h) => format!("{h} hop(s) from a change"),
446 None => "post-pass candidate".to_string(),
447 }
448}
449
450pub fn build_locate(state: &ScoredState, outcome: &SelectionOutcome) -> LocateOutput {
454 let rel = &state.scoring_result.rel_scores;
455 let hops = seed_hops(state);
456 let attribution = incoming_attribution(state);
457 let (overflow, overflow_count, next_up) = build_overflow(state, outcome, &hops, &attribution);
458
459 let items: Vec<LocateItem> = outcome
460 .selected
461 .iter()
462 .map(|frag| {
463 let is_changed = state.core_ids.contains(&frag.id)
464 || frag.kind == crate::types::FragmentKind::Excerpt;
465 let path = rel_path(state, frag.id.path.as_ref());
466 let group = group_of(&path, frag.kind);
467 LocateItem {
468 path,
469 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
470 kind: format!("{:?}", frag.kind).to_lowercase(),
471 symbol: frag.symbol_name.clone(),
472 role: if is_changed { Some("changed") } else { None },
473 group,
474 score: rel
475 .get(&frag.id)
476 .map(|s| (s * 1e4).round() / 1e4)
477 .unwrap_or(0.0),
478 tokens: frag.token_count,
479 reasons: reasons_for(
480 state,
481 frag,
482 hops.get(&frag.id).copied(),
483 attribution.get(&frag.id),
484 ),
485 }
486 })
487 .collect();
488
489 LocateOutput {
490 schema: LOCATE_SCHEMA,
491 name: state
492 .root_dir
493 .file_name()
494 .map(|n| n.to_string_lossy().to_string())
495 .unwrap_or_else(|| state.root_dir.to_string_lossy().to_string()),
496 commit_message: state.commit_message.clone(),
497 changed_files: state
498 .changed_files
499 .iter()
500 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
501 .collect(),
502 deleted_files: state.deleted_files.clone(),
503 renamed_files: state
504 .renamed_files
505 .iter()
506 .map(|(from, to)| RenameEntry {
507 from: from.clone(),
508 to: to.clone(),
509 })
510 .collect(),
511 lockfile_changes: state.lockfile_changes.clone(),
512 ignored_changes: state.ignored_changes.clone(),
513 policy_excluded_count: state.policy_excluded_count,
514 budget_tokens: outcome.effective_budget,
515 summary: Summary {
516 files: items
517 .iter()
518 .map(|i| i.path.as_str())
519 .collect::<std::collections::BTreeSet<_>>()
520 .len(),
521 changed: items.iter().filter(|i| i.role == Some("changed")).count(),
522 context: items.iter().filter(|i| i.role.is_none()).count(),
523 tests: items.iter().filter(|i| i.group == Some("test")).count(),
524 },
525 item_count: items.len(),
526 items,
527 coverage: build_coverage(state, outcome, next_up, &attribution),
528 overflow,
529 overflow_count,
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use crate::types::FragmentKind;
537
538 #[test]
545 fn the_grouping_uses_the_shared_test_classifier() {
546 for path in [
547 "src/main/java/com/example/FooTest.java",
548 "src/main/scala/AuthSpec.scala",
549 "src/XMLTest.java",
550 "ui/widget-spec.js",
551 "src/tests.rs",
552 ] {
553 assert_eq!(
554 group_of(path, FragmentKind::Function),
555 Some("test"),
556 "not grouped as a test: {path}"
557 );
558 }
559 }
560
561 #[test]
565 fn a_testing_directory_is_not_itself_a_test() {
566 assert_eq!(
567 group_of("src/testing/helpers.go", FragmentKind::Function),
568 None
569 );
570 }
571
572 #[test]
576 fn a_type_in_a_test_file_groups_as_test() {
577 assert_eq!(
578 group_of("tests/fixtures.rs", FragmentKind::Struct),
579 Some("test")
580 );
581 assert_eq!(group_of("src/model.rs", FragmentKind::Struct), Some("type"));
582 }
583}