1use std::collections::{BTreeMap, VecDeque};
44use std::fs::{self, File};
45use std::io::Read;
46use std::path::{Path, PathBuf};
47
48use serde::Serialize;
49
50use crate::internal_error::InternalError;
51use crate::policy::Producer;
52use crate::report::{Diagnostic, DiagnosticSource, DiagnosticSpan};
53use crate::workspace_path;
54
55const STAGE: &str = "delta";
56pub(crate) const FINGERPRINT_VERSION: u8 = 1;
57pub(crate) const DIAGNOSTIC_LIMIT: usize = 50_000;
58const PROOF_BYTES_LIMIT: usize = 65_536;
59const SOURCE_FILE_BYTES_LIMIT: usize = 8 * 1024 * 1024;
60const SOURCE_BYTES_BUDGET: usize = 64 * 1024 * 1024;
61const PROOF_BYTES_BUDGET: usize = 64 * 1024 * 1024;
62const FINGERPRINT_DOMAIN: &str = "rust-doctor-delta-fingerprint-v1";
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct DeltaReport {
66 pub fingerprint_version: u8,
67 pub base_diagnostics: usize,
68 pub current_diagnostics: usize,
69 pub introduced: Vec<String>,
70 pub pre_existing: Vec<DeltaMatch>,
71 pub fixed: Vec<Diagnostic>,
72 pub summary: DeltaSummary,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76pub struct DeltaMatch {
77 pub current_id: String,
78 pub baseline_id: String,
79}
80
81#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
82pub struct DeltaSummary {
83 pub introduced: usize,
84 pub pre_existing: usize,
85 pub fixed: usize,
86 pub cross_file_matches: usize,
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
90struct DeltaFingerprintV1([u8; 32]);
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
98struct FallbackKey<'a> {
99 source: DiagnosticSource,
100 code: Option<&'a str>,
101 message: &'a str,
102}
103
104#[derive(Debug)]
106struct Candidate<'a> {
107 diagnostic: &'a Diagnostic,
108 fingerprint: Option<DeltaFingerprintV1>,
109}
110
111impl<'a> Candidate<'a> {
112 fn new(diagnostic: &'a Diagnostic) -> Self {
113 Self {
114 diagnostic,
115 fingerprint: structural_identity(diagnostic).map(structural_fingerprint),
116 }
117 }
118
119 fn path(&self) -> Option<&'a str> {
120 self.diagnostic.path.as_deref()
121 }
122
123 fn fallback(&self) -> FallbackKey<'a> {
124 FallbackKey {
125 source: self.diagnostic.source,
126 code: self.diagnostic.code.as_deref(),
127 message: self.diagnostic.message.as_str(),
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
133struct SourcePosition {
134 line: usize,
135 column: usize,
136}
137
138struct LineIndex(Vec<usize>);
146
147impl LineIndex {
148 fn of(source: &str) -> Self {
149 Self(
150 std::iter::once(0)
151 .chain(source.match_indices('\n').map(|(index, _)| index + 1))
152 .collect(),
153 )
154 }
155
156 fn offset(&self, source: &str, position: SourcePosition) -> Option<usize> {
164 let start = *self.0.get(position.line.checked_sub(1)?)?;
165 let next_line = self.0.get(position.line).copied();
166 let line = source.get(start..next_line.unwrap_or(source.len()))?;
167 let column = position.column.checked_sub(1)?;
168 match line.char_indices().nth(column) {
169 Some((offset, _)) => start.checked_add(offset),
170 None if next_line.is_none() && column == line.chars().count() => Some(source.len()),
171 None => None,
172 }
173 }
174}
175
176struct EvidenceLoader {
179 root: Option<PathBuf>,
180 source_bytes_read: usize,
181 proof_bytes: usize,
182}
183
184impl EvidenceLoader {
185 fn new(root: &Path) -> Self {
186 Self {
187 root: root.canonicalize().ok(),
188 source_bytes_read: 0,
189 proof_bytes: 0,
190 }
191 }
192
193 fn populate<'a>(&mut self, candidates: &mut [Candidate<'a>]) {
194 let Some(root) = self.root.clone() else {
195 return;
196 };
197 let mut by_path = BTreeMap::<&'a str, Vec<usize>>::new();
198 for (index, candidate) in candidates.iter().enumerate() {
199 if candidate.fingerprint.is_none()
203 && candidate.diagnostic.span.is_some()
204 && let Some(path) = candidate.path()
205 {
206 by_path.entry(path).or_default().push(index);
207 }
208 }
209
210 for (path, indexes) in by_path {
211 let Some(source) = self.read_source(&root, path) else {
212 continue;
213 };
214 let lines = LineIndex::of(&source);
215 for index in indexes {
216 let Some(diagnostic) = candidates.get(index).map(|candidate| candidate.diagnostic)
217 else {
218 continue;
219 };
220 let Some(span) = diagnostic.span.as_ref() else {
221 continue;
222 };
223 let Some(proof) = self.proof(&source, &lines, span) else {
224 continue;
225 };
226 let fingerprint = stable_fingerprint(diagnostic, &proof);
227 if let Some(candidate) = candidates.get_mut(index) {
228 candidate.fingerprint = Some(fingerprint);
229 }
230 }
231 }
232 }
233
234 fn read_source(&mut self, root: &Path, logical_path: &str) -> Option<String> {
235 let relative = workspace_path::decode_normalized_relative(logical_path)?;
236 let path = root.join(relative).canonicalize().ok()?;
237 if !path.starts_with(root) {
238 return None;
239 }
240 let metadata = fs::symlink_metadata(&path).ok()?;
244 let length = usize::try_from(metadata.len()).ok()?;
245 if !metadata.is_file()
246 || length > SOURCE_FILE_BYTES_LIMIT
247 || self.source_bytes_read.checked_add(length)? > SOURCE_BYTES_BUDGET
248 {
249 return None;
250 }
251 self.source_bytes_read = self.source_bytes_read.checked_add(length)?;
252
253 let file = File::open(&path).ok()?;
257 let opened = file.metadata().ok()?;
258 let revalidated = path.canonicalize().ok()?;
259 if !opened.is_file()
260 || !revalidated.starts_with(root)
261 || !workspace_path::same_file(&opened, &fs::metadata(&revalidated).ok()?)
262 {
263 return None;
264 }
265
266 let mut source = String::with_capacity(length);
267 file.take(SOURCE_FILE_BYTES_LIMIT as u64)
268 .read_to_string(&mut source)
269 .ok()?;
270 (source.len() == length).then_some(source)
271 }
272
273 fn proof(&mut self, source: &str, lines: &LineIndex, span: &DiagnosticSpan) -> Option<String> {
274 let remaining = PROOF_BYTES_BUDGET.checked_sub(self.proof_bytes)?;
275 if remaining == 0 {
276 return None;
277 }
278 let proof = extract_proof(source, lines, span, remaining)?;
279 self.proof_bytes = self.proof_bytes.checked_add(proof.len())?;
280 Some(proof)
281 }
282}
283
284pub(crate) fn compute(
285 baseline: &[Diagnostic],
286 current: &[Diagnostic],
287 baseline_root: &Path,
288 current_root: &Path,
289) -> Result<DeltaReport, InternalError> {
290 if baseline.len() > DIAGNOSTIC_LIMIT || current.len() > DIAGNOSTIC_LIMIT {
291 return Err(limit_exceeded());
292 }
293
294 let baseline_candidates = candidates(baseline, baseline_root);
295 let current_candidates = candidates(current, current_root);
296 Ok(match_candidates(&baseline_candidates, ¤t_candidates))
297}
298
299fn limit_exceeded() -> InternalError {
300 InternalError::new(
301 STAGE,
302 "delta-limit-exceeded",
303 format!("Baseline comparison exceeds {DIAGNOSTIC_LIMIT} diagnostics on one side."),
304 )
305}
306
307fn candidates<'a>(diagnostics: &'a [Diagnostic], root: &Path) -> Vec<Candidate<'a>> {
308 let mut candidates = diagnostics.iter().map(Candidate::new).collect::<Vec<_>>();
309 EvidenceLoader::new(root).populate(&mut candidates);
310 candidates
311}
312
313fn structural_identity(diagnostic: &Diagnostic) -> Option<&str> {
325 let definition = crate::policy::find(diagnostic.code.as_deref()?)?;
326 matches!(definition.producer, Producer::Structure).then_some(diagnostic.id.as_str())
327}
328
329fn structural_fingerprint(identity: &str) -> DeltaFingerprintV1 {
330 let mut hasher = blake3::Hasher::new();
331 hash_field(&mut hasher, FINGERPRINT_DOMAIN.as_bytes());
332 hash_field(&mut hasher, b"structure");
333 hash_field(&mut hasher, identity.as_bytes());
334 DeltaFingerprintV1(*hasher.finalize().as_bytes())
335}
336
337fn stable_fingerprint(diagnostic: &Diagnostic, proof: &str) -> DeltaFingerprintV1 {
338 let mut hasher = blake3::Hasher::new();
339 hash_field(&mut hasher, FINGERPRINT_DOMAIN.as_bytes());
340 hash_field(&mut hasher, diagnostic.source.as_str().as_bytes());
341 match diagnostic.code.as_deref() {
342 Some(code) => {
343 hasher.update(&[1]);
344 hash_field(&mut hasher, code.as_bytes());
345 }
346 None => {
347 hasher.update(&[0]);
348 }
349 }
350 hash_field(&mut hasher, diagnostic.message.as_bytes());
351 hash_field(&mut hasher, proof.as_bytes());
352 DeltaFingerprintV1(*hasher.finalize().as_bytes())
353}
354
355fn hash_field(hasher: &mut blake3::Hasher, value: &[u8]) {
356 hasher.update(&(value.len() as u64).to_le_bytes());
357 hasher.update(value);
358}
359
360fn extract_proof(
361 source: &str,
362 lines: &LineIndex,
363 span: &DiagnosticSpan,
364 remaining_budget: usize,
365) -> Option<String> {
366 let start = lines.offset(
367 source,
368 SourcePosition {
369 line: span.line_start,
370 column: span.column_start,
371 },
372 )?;
373 let end = lines.offset(
374 source,
375 SourcePosition {
376 line: span.line_end,
377 column: span.column_end,
378 },
379 )?;
380 if start > end || end.checked_sub(start)? > PROOF_BYTES_LIMIT {
381 return None;
382 }
383 normalize_proof(source.get(start..end)?, remaining_budget)
384}
385
386fn normalize_proof(source: &str, remaining_budget: usize) -> Option<String> {
393 let bound = remaining_budget.min(PROOF_BYTES_LIMIT);
394 let mut normalized = String::with_capacity(source.len().min(bound));
395 for segment in source.split_whitespace() {
396 let separator = usize::from(!normalized.is_empty());
397 if normalized.len() + separator + segment.len() > bound {
398 return None;
399 }
400 if separator == 1 {
401 normalized.push(' ');
402 }
403 normalized.push_str(segment);
404 }
405 (!normalized.is_empty()).then_some(normalized)
406}
407
408fn same_path_stable<'a>(
411 candidate: &Candidate<'a>,
412) -> Option<(Option<&'a str>, DeltaFingerprintV1)> {
413 Some((candidate.path(), candidate.fingerprint?))
414}
415
416fn same_path_unproven<'a>(candidate: &Candidate<'a>) -> Option<(Option<&'a str>, FallbackKey<'a>)> {
418 candidate
419 .fingerprint
420 .is_none()
421 .then(|| (candidate.path(), candidate.fallback()))
422}
423
424fn same_path_proven<'a>(candidate: &Candidate<'a>) -> Option<(Option<&'a str>, FallbackKey<'a>)> {
426 candidate
427 .fingerprint
428 .is_some()
429 .then(|| (candidate.path(), candidate.fallback()))
430}
431
432fn same_path_fallback<'a>(candidate: &Candidate<'a>) -> Option<(Option<&'a str>, FallbackKey<'a>)> {
434 Some((candidate.path(), candidate.fallback()))
435}
436
437fn moved_stable(candidate: &Candidate<'_>) -> Option<DeltaFingerprintV1> {
439 candidate.fingerprint
440}
441
442fn match_candidates(baseline: &[Candidate<'_>], current: &[Candidate<'_>]) -> DeltaReport {
443 let mut matching = Matching::new(baseline, current);
444
445 matching.pass(same_path_stable, same_path_stable);
449 matching.pass(same_path_unproven, same_path_proven);
453 matching.pass(same_path_fallback, same_path_unproven);
454 let moved = matching.pass(moved_stable, moved_stable);
460
461 debug_assert!(
462 moved.iter().all(|&(baseline_index, current_index)| {
463 baseline.get(baseline_index).map(Candidate::path)
464 != current.get(current_index).map(Candidate::path)
465 }),
466 "same-path stable candidates must be exhausted before cross-file matching"
467 );
468 matching.into_report(moved.len())
469}
470
471struct Matching<'a, 'd> {
474 baseline: &'a [Candidate<'d>],
475 current: &'a [Candidate<'d>],
476 consumed: Vec<bool>,
477 matched: Vec<Option<usize>>,
478}
479
480impl<'a, 'd> Matching<'a, 'd> {
481 fn new(baseline: &'a [Candidate<'d>], current: &'a [Candidate<'d>]) -> Self {
482 Self {
483 baseline,
484 current,
485 consumed: vec![false; baseline.len()],
486 matched: vec![None; current.len()],
487 }
488 }
489
490 fn pass<Key: Ord>(
497 &mut self,
498 baseline_key: impl Fn(&Candidate<'d>) -> Option<Key>,
499 current_key: impl Fn(&Candidate<'d>) -> Option<Key>,
500 ) -> Vec<(usize, usize)> {
501 let (baseline, current) = (self.baseline, self.current);
502 let mut available = BTreeMap::<Key, VecDeque<usize>>::new();
503 for (index, candidate) in baseline.iter().enumerate() {
504 if self.consumed.get(index).is_some_and(|consumed| !consumed)
505 && let Some(key) = baseline_key(candidate)
506 {
507 available.entry(key).or_default().push_back(index);
508 }
509 }
510
511 let mut made = Vec::new();
512 for (current_index, candidate) in current.iter().enumerate() {
513 if self.matched.get(current_index).is_none_or(Option::is_some) {
514 continue;
515 }
516 let Some(key) = current_key(candidate) else {
517 continue;
518 };
519 let Some(baseline_index) = available.get_mut(&key).and_then(VecDeque::pop_front) else {
520 continue;
521 };
522 if let Some(consumed) = self.consumed.get_mut(baseline_index) {
523 *consumed = true;
524 }
525 if let Some(matched) = self.matched.get_mut(current_index) {
526 *matched = Some(baseline_index);
527 }
528 made.push((baseline_index, current_index));
529 }
530 made
531 }
532
533 fn into_report(self, cross_file_matches: usize) -> DeltaReport {
534 let introduced = self
535 .current
536 .iter()
537 .zip(&self.matched)
538 .filter(|(_, matched)| matched.is_none())
539 .map(|(candidate, _)| candidate.diagnostic.id.clone())
540 .collect::<Vec<_>>();
541 let pre_existing = self
542 .current
543 .iter()
544 .zip(&self.matched)
545 .filter_map(|(candidate, matched)| {
546 Some(DeltaMatch {
547 current_id: candidate.diagnostic.id.clone(),
548 baseline_id: self.baseline.get((*matched)?)?.diagnostic.id.clone(),
549 })
550 })
551 .collect::<Vec<_>>();
552 let fixed = self
553 .baseline
554 .iter()
555 .zip(&self.consumed)
556 .filter(|(_, consumed)| !**consumed)
557 .map(|(candidate, _)| candidate.diagnostic.clone())
558 .collect::<Vec<_>>();
559
560 DeltaReport {
561 fingerprint_version: FINGERPRINT_VERSION,
562 base_diagnostics: self.baseline.len(),
563 current_diagnostics: self.current.len(),
564 summary: DeltaSummary {
565 introduced: introduced.len(),
566 pre_existing: pre_existing.len(),
567 fixed: fixed.len(),
568 cross_file_matches,
569 },
570 introduced,
571 pre_existing,
572 fixed,
573 }
574 }
575}
576
577#[cfg(test)]
578mod tests;