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_pub", 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 = "is_zero")]
49 pub overflow_count: usize,
50}
51
52#[allow(clippy::trivially_copy_pass_by_ref)]
54fn is_zero(n: &usize) -> bool {
55 *n == 0
56}
57
58pub const MAX_OVERFLOW_ITEMS: usize = 50;
62
63#[derive(Serialize, Default)]
64pub struct Coverage {
65 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub unparsed_files: Vec<String>,
70 #[serde(skip_serializing_if = "Vec::is_empty")]
74 pub zero_edge_files: Vec<String>,
75 #[serde(skip_serializing_if = "std::ops::Not::not")]
79 pub ppr_truncated: bool,
80 #[serde(skip_serializing_if = "is_zero")]
89 pub next_up: usize,
90 pub confidence: f64,
95}
96
97impl Coverage {
98 pub fn is_clean(&self) -> bool {
102 self.unparsed_files.is_empty()
103 && self.zero_edge_files.is_empty()
104 && !self.ppr_truncated
105 && self.next_up == 0
106 }
107}
108
109#[derive(Serialize)]
110pub struct OverflowItem {
111 pub path: String,
112 pub lines: String,
113 pub score: f64,
114 pub tokens: u32,
115 pub why: String,
118}
119
120#[derive(Serialize)]
121pub struct Summary {
122 pub files: usize,
123 pub changed: usize,
124 pub context: usize,
125 pub tests: usize,
126}
127
128#[derive(Serialize)]
129pub struct RenameEntry {
130 pub from: String,
131 pub to: String,
132}
133
134#[derive(Serialize)]
137pub struct LocateItem {
138 pub path: String,
139 pub lines: String,
140 pub kind: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub symbol: Option<String>,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 pub role: Option<&'static str>,
145 #[serde(skip_serializing_if = "Option::is_none")]
148 pub group: Option<&'static str>,
149 pub score: f64,
150 pub tokens: u32,
151 pub reasons: Vec<Reason>,
152}
153
154fn is_test_path(path: &str) -> bool {
166 crate::testfiles::is_test_path(Path::new(path))
167}
168
169const CONFIG_EXTENSIONS: &[&str] = &[
170 "yaml",
171 "yml",
172 "json",
173 "toml",
174 "ini",
175 "cfg",
176 "conf",
177 "env",
178 "properties",
179];
180
181fn group_of(path: &str, kind: crate::types::FragmentKind) -> Option<&'static str> {
182 use crate::types::FragmentKind as K;
183 if is_test_path(path) {
184 return Some("test");
185 }
186 if matches!(
187 kind,
188 K::Struct
189 | K::Enum
190 | K::Interface
191 | K::Type
192 | K::Record
193 | K::StructSignature
194 | K::ClassSignature
195 ) {
196 return Some("type");
197 }
198 let ext = path.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
199 if CONFIG_EXTENSIONS.contains(&ext.to_lowercase().as_str()) {
200 return Some("config");
201 }
202 None
203}
204
205#[derive(Serialize)]
206#[serde(tag = "type", rename_all = "snake_case")]
207pub enum Reason {
208 Changed,
210 Edge {
212 category: String,
213 from: String,
214 mass: f64,
215 },
216 Proximity { seed_hops: u32 },
218 PostPass,
221}
222
223fn rel_path(state: &ScoredState, path: &str) -> String {
229 crate::paths::display_rel(&state.root_dir, Path::new(path))
230 .unwrap_or_else(|| crate::paths::to_posix_display(std::borrow::Cow::Borrowed(path)))
231}
232
233fn reasons_for(
234 state: &ScoredState,
235 frag: &Fragment,
236 hops: Option<u32>,
237 attribution: Option<&Vec<(String, String, f64)>>,
238) -> Vec<Reason> {
239 if state.core_ids.contains(&frag.id) || frag.kind == crate::types::FragmentKind::Excerpt {
240 return vec![Reason::Changed];
241 }
242 let mut reasons: Vec<Reason> = Vec::new();
243 if let Some(per_cat) = attribution {
244 if let Some((category, from, mass)) = per_cat.first() {
247 reasons.push(Reason::Edge {
248 category: category.clone(),
249 from: rel_path(state, from),
250 mass: (mass * 1e3).round() / 1e3,
251 });
252 }
253 }
254 if let Some(h) = hops {
255 reasons.push(Reason::Proximity { seed_hops: h });
256 }
257 if reasons.is_empty() {
258 reasons.push(Reason::PostPass);
259 }
260 reasons
261}
262
263fn is_structural(kind: crate::types::FragmentKind) -> bool {
270 use crate::types::FragmentKind as K;
271 !matches!(kind, K::Chunk | K::Excerpt)
272}
273
274fn build_coverage(
275 state: &ScoredState,
276 outcome: &SelectionOutcome,
277 next_up: usize,
278 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
279) -> Coverage {
280 let graph = &state.scoring_result.graph;
281 let changed: Vec<String> = state
282 .changed_files
283 .iter()
284 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
285 .collect();
286
287 let mut by_file: FxHashMap<String, Vec<&Fragment>> = FxHashMap::default();
292 for f in &state.all_fragments {
293 by_file
294 .entry(rel_path(state, f.id.path.as_ref()))
295 .or_default()
296 .push(f);
297 }
298
299 let mut unparsed: Vec<String> = Vec::new();
300 let mut zero_edge: Vec<String> = Vec::new();
301 let empty: Vec<&Fragment> = Vec::new();
302 for file in &changed {
303 let frags = by_file.get(file).unwrap_or(&empty);
304 if frags.is_empty() {
305 continue;
309 }
310 let parseable = crate::languages::get_language_for_file(file).is_some();
314 if parseable && !frags.iter().any(|f| is_structural(f.kind)) {
315 unparsed.push(file.clone());
316 }
317 let linked = frags.iter().any(|f| {
318 if attribution.contains_key(&f.id) {
319 return true;
320 }
321 let mut has_out = false;
322 graph.for_each_forward_neighbor(&f.id, |_, _| has_out = true);
323 has_out
324 });
325 if !linked {
326 zero_edge.push(file.clone());
327 }
328 }
329 unparsed.sort();
330 unparsed.dedup();
331 zero_edge.sort();
332 zero_edge.dedup();
333
334 let n_changed = changed.len().max(1) as f64;
335 let parsed_share = 1.0 - unparsed.len() as f64 / n_changed;
336 let linked_share = 1.0 - zero_edge.len() as f64 / n_changed;
337 let selected_context = outcome
341 .selected
342 .iter()
343 .filter(|f| !state.core_ids.contains(&f.id))
344 .count();
345 let fit_share = if selected_context + next_up == 0 {
350 1.0
351 } else {
352 selected_context as f64 / (selected_context + next_up) as f64
353 };
354 let truncated = state.scoring_result.ppr_truncated;
355 let raw = parsed_share * linked_share * fit_share - if truncated { 0.1 } else { 0.0 };
356
357 Coverage {
358 unparsed_files: unparsed,
359 zero_edge_files: zero_edge,
360 ppr_truncated: truncated,
361 next_up,
362 confidence: (raw.clamp(0.0, 1.0) * 1e2).round() / 1e2,
363 }
364}
365
366fn build_overflow(
374 state: &ScoredState,
375 outcome: &SelectionOutcome,
376 hops: &FxHashMap<FragmentId, u32>,
377 attribution: &FxHashMap<FragmentId, Vec<(String, String, f64)>>,
378) -> (Vec<OverflowItem>, usize, usize) {
379 let selected: FxHashSet<&FragmentId> = outcome.selected.iter().map(|f| &f.id).collect();
380 let rel = &state.scoring_result.rel_scores;
381 let mut skipped: Vec<&Fragment> = state
382 .scoring_result
383 .filtered_fragments
384 .iter()
385 .filter(|f| !selected.contains(&f.id) && !state.core_ids.contains(&f.id))
386 .collect();
387 skipped.sort_by(|a, b| {
388 let sa = rel.get(&a.id).copied().unwrap_or(0.0);
389 let sb = rel.get(&b.id).copied().unwrap_or(0.0);
390 sb.total_cmp(&sa).then_with(|| a.id.cmp(&b.id))
391 });
392 let total = skipped.len();
393 let spent: u32 = outcome.selected.iter().map(|f| f.token_count).sum();
398 let headroom = outcome.effective_budget.saturating_sub(spent);
399 let smallest_skipped = skipped.iter().map(|f| f.token_count).min().unwrap_or(0);
400 let budget_bound = !skipped.is_empty() && headroom < smallest_skipped;
401 let next_up = if budget_bound {
402 let extra = outcome.effective_budget / 4;
406 let mut spare = headroom + extra;
407 let mut n = 0;
408 for f in &skipped {
409 if f.token_count > spare {
410 break;
411 }
412 spare -= f.token_count;
413 n += 1;
414 }
415 n
416 } else {
417 0
418 };
419 let items = skipped
420 .into_iter()
421 .take(MAX_OVERFLOW_ITEMS)
422 .map(|frag| OverflowItem {
423 path: rel_path(state, frag.id.path.as_ref()),
424 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
425 score: rel
426 .get(&frag.id)
427 .map(|s| (s * 1e4).round() / 1e4)
428 .unwrap_or(0.0),
429 tokens: frag.token_count,
430 why: overflow_why(
431 state,
432 hops.get(&frag.id).copied(),
433 attribution.get(&frag.id),
434 ),
435 })
436 .collect();
437 (items, total, next_up)
438}
439
440fn overflow_why(
441 state: &ScoredState,
442 hops: Option<u32>,
443 attribution: Option<&Vec<(String, String, f64)>>,
444) -> String {
445 if let Some((category, from, _)) = attribution.and_then(|rows| rows.first()) {
446 return format!("{category} from {}", rel_path(state, from));
447 }
448 match hops {
449 Some(h) => format!("{h} hop(s) from a change"),
450 None => "post-pass candidate".to_string(),
451 }
452}
453
454pub fn build_locate(state: &ScoredState, outcome: &SelectionOutcome) -> LocateOutput {
458 let rel = &state.scoring_result.rel_scores;
459 let hops = seed_hops(state);
460 let attribution = incoming_attribution(state);
461 let (overflow, overflow_count, next_up) = build_overflow(state, outcome, &hops, &attribution);
462
463 let items: Vec<LocateItem> = outcome
464 .selected
465 .iter()
466 .map(|frag| {
467 let is_changed = state.core_ids.contains(&frag.id)
468 || frag.kind == crate::types::FragmentKind::Excerpt;
469 let path = rel_path(state, frag.id.path.as_ref());
470 let group = group_of(&path, frag.kind);
471 LocateItem {
472 path,
473 lines: format!("{}-{}", frag.id.start_line, frag.id.end_line),
474 kind: format!("{:?}", frag.kind).to_lowercase(),
475 symbol: frag.symbol_name.clone(),
476 role: if is_changed { Some("changed") } else { None },
477 group,
478 score: rel
479 .get(&frag.id)
480 .map(|s| (s * 1e4).round() / 1e4)
481 .unwrap_or(0.0),
482 tokens: frag.token_count,
483 reasons: reasons_for(
484 state,
485 frag,
486 hops.get(&frag.id).copied(),
487 attribution.get(&frag.id),
488 ),
489 }
490 })
491 .collect();
492
493 LocateOutput {
494 schema: LOCATE_SCHEMA,
495 name: state
496 .root_dir
497 .file_name()
498 .map(|n| n.to_string_lossy().to_string())
499 .unwrap_or_else(|| state.root_dir.to_string_lossy().to_string()),
500 commit_message: state.commit_message.clone(),
501 changed_files: state
502 .changed_files
503 .iter()
504 .map(|p| rel_path(state, p.to_string_lossy().as_ref()))
505 .collect(),
506 deleted_files: state.deleted_files.clone(),
507 renamed_files: state
508 .renamed_files
509 .iter()
510 .map(|(from, to)| RenameEntry {
511 from: from.clone(),
512 to: to.clone(),
513 })
514 .collect(),
515 lockfile_changes: state.lockfile_changes.clone(),
516 ignored_changes: state.ignored_changes.clone(),
517 policy_excluded_count: state.policy_excluded_count,
518 budget_tokens: outcome.effective_budget,
519 summary: Summary {
520 files: items
521 .iter()
522 .map(|i| i.path.as_str())
523 .collect::<std::collections::BTreeSet<_>>()
524 .len(),
525 changed: items.iter().filter(|i| i.role == Some("changed")).count(),
526 context: items.iter().filter(|i| i.role.is_none()).count(),
527 tests: items.iter().filter(|i| i.group == Some("test")).count(),
528 },
529 item_count: items.len(),
530 items,
531 coverage: build_coverage(state, outcome, next_up, &attribution),
532 overflow,
533 overflow_count,
534 }
535}
536
537#[cfg(test)]
538mod tests {
539 use super::*;
540 use crate::types::FragmentKind;
541
542 #[test]
549 fn the_grouping_uses_the_shared_test_classifier() {
550 for path in [
551 "src/main/java/com/example/FooTest.java",
552 "src/main/scala/AuthSpec.scala",
553 "src/XMLTest.java",
554 "ui/widget-spec.js",
555 "src/tests.rs",
556 ] {
557 assert_eq!(
558 group_of(path, FragmentKind::Function),
559 Some("test"),
560 "not grouped as a test: {path}"
561 );
562 }
563 }
564
565 #[test]
569 fn a_testing_directory_is_not_itself_a_test() {
570 assert_eq!(
571 group_of("src/testing/helpers.go", FragmentKind::Function),
572 None
573 );
574 }
575
576 #[test]
580 fn a_type_in_a_test_file_groups_as_test() {
581 assert_eq!(
582 group_of("tests/fixtures.rs", FragmentKind::Struct),
583 Some("test")
584 );
585 assert_eq!(group_of("src/model.rs", FragmentKind::Struct), Some("type"));
586 }
587}