1use std::path::{Path, PathBuf};
2
3use code_moniker_core::core::code_graph::CodeGraph;
4use code_moniker_core::lang::Lang;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::environment;
8
9use super::super::diff::{
10 ChangeFile, ChangeScan, DiffHunk, DiffScope, FileDiff, FileDiffStatus, GitWorktree, HeadSide,
11 anchor_for, collect_changed_files, diff_path, display_rel_path, git_show, normalize_path,
12 resolve_base_rev, source_root_for_path,
13};
14use super::model::{HunkCoverage, RefChange, SymbolChange};
15use super::pairing::{FilePairing, FileSide, PairInputs, finish_files, pair_file};
16use super::refpairs::{CoverageInputs, RenameContext, hunk_coverage, pair_refs};
17use super::rollup::{FileDisposition, FileRollup, moved_file_rollup};
18
19#[derive(Clone, Debug, Default, Eq, PartialEq)]
20pub struct SemanticReview {
21 pub scope: String,
22 pub symbol_changes: Vec<SymbolChange>,
23 pub ref_changes: Vec<RefChange>,
24 pub files: Vec<FileFacts>,
25 pub diagnostics: Vec<String>,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct FileFacts {
30 pub rollup: FileRollup,
31 pub coverage: HunkCoverage,
32 pub analyzable: bool,
33}
34
35pub struct ReviewDiffs {
36 diffs: Vec<FileDiff>,
37 pub diagnostics: Vec<String>,
38 scope_label: String,
39 head_rev: Option<String>,
40 base_by_root: FxHashMap<PathBuf, String>,
41}
42
43impl ReviewDiffs {
44 pub fn current_paths(&self) -> Vec<PathBuf> {
45 self.diffs
46 .iter()
47 .filter(|diff| diff.status != FileDiffStatus::Deleted)
48 .map(|diff| normalize_path(&diff_path(diff)))
49 .collect()
50 }
51
52 pub fn current_rows(&self) -> Vec<(&Path, &Path)> {
53 self.diffs
54 .iter()
55 .filter(|diff| diff.status != FileDiffStatus::Deleted)
56 .map(|diff| (diff.repo_root.as_path(), diff.repo_rel.as_path()))
57 .collect()
58 }
59
60 pub fn head_rev(&self) -> Option<&str> {
61 self.head_rev.as_deref()
62 }
63
64 pub fn any_root_resolved(&self) -> bool {
65 !self.base_by_root.is_empty()
66 }
67
68 fn base_rev_for(&self, repo_root: &Path) -> &str {
69 self.base_by_root
70 .get(repo_root)
71 .map(String::as_str)
72 .unwrap_or("HEAD")
73 }
74}
75
76pub fn read_blob(repo_root: &Path, rev: &str, repo_rel: &Path) -> anyhow::Result<String> {
77 git_show(repo_root, rev, repo_rel)
78}
79
80pub fn collect_review_diffs(roots: &[(String, PathBuf)]) -> ReviewDiffs {
81 collect_review_diffs_scoped(roots, &DiffScope::worktree())
82}
83
84pub fn collect_review_diffs_scoped(roots: &[(String, PathBuf)], scope: &DiffScope) -> ReviewDiffs {
85 let mut review_diffs = ReviewDiffs {
86 diffs: Vec::new(),
87 diagnostics: Vec::new(),
88 scope_label: scope.label(),
89 head_rev: match &scope.head {
90 HeadSide::Rev(rev) => Some(rev.clone()),
91 HeadSide::Worktree => None,
92 },
93 base_by_root: FxHashMap::default(),
94 };
95 for (label, path) in roots {
96 collect_root_diffs(&mut review_diffs, label, path, scope);
97 }
98 review_diffs
99}
100
101fn collect_root_diffs(out: &mut ReviewDiffs, label: &str, path: &Path, scope: &DiffScope) {
102 let repo = match GitWorktree::discover(path) {
103 Ok(repo) => repo,
104 Err(message) => {
105 out.diagnostics.push(message);
106 return;
107 }
108 };
109 let base_rev = match resolve_base_rev(repo.root(), &scope.base) {
110 Ok(base_rev) => base_rev,
111 Err(message) => {
112 out.diagnostics.push(format!("{label}: {message}"));
113 return;
114 }
115 };
116 match collect_changed_files(repo.root(), path, &base_rev, &scope.head) {
117 Ok(mut root_diffs) => out.diffs.append(&mut root_diffs),
118 Err(error) => out
119 .diagnostics
120 .push(format!("{label}: cannot inspect git changes: {error}")),
121 }
122 out.base_by_root.insert(repo.root().to_path_buf(), base_rev);
123}
124
125pub fn build_semantic_review(scan: &ChangeScan<'_>) -> SemanticReview {
126 let roots: Vec<(String, PathBuf)> = scan
127 .roots
128 .iter()
129 .map(|root| (root.label.to_string(), root.path.to_path_buf()))
130 .collect();
131 build_semantic_review_from(scan, &collect_review_diffs(&roots))
132}
133
134pub fn build_semantic_review_from(scan: &ChangeScan<'_>, diffs: &ReviewDiffs) -> SemanticReview {
135 let mut review = SemanticReview {
136 scope: diffs.scope_label.clone(),
137 diagnostics: diffs.diagnostics.clone(),
138 ..SemanticReview::default()
139 };
140 let pairs = review_pairs(scan, diffs, &mut review);
141 let pairings: Vec<FilePairing> = pairs
142 .iter()
143 .map(|pair| {
144 pair_file(PairInputs {
145 base: pair.base_side(),
146 current: pair.current_side(),
147 file_moved: pair.file_moved,
148 })
149 })
150 .collect();
151 review.symbol_changes = finish_files(pairings);
152 let ctx = rename_context(&review.symbol_changes, &pairs);
153 for pair in &pairs {
154 let refs = pair_refs(&pair.base_side(), &pair.current_side(), &ctx);
155 review
156 .files
157 .push(pair_facts(pair, &review.symbol_changes, &refs));
158 review.ref_changes.extend(refs);
159 }
160 review.files.sort_by_key(facts_order);
161 review
162}
163
164type LineSpans = Vec<(u32, u32)>;
165
166fn facts_order(facts: &FileFacts) -> (Option<PathBuf>, Option<PathBuf>) {
167 (
168 facts
169 .rollup
170 .new_path
171 .clone()
172 .or(facts.rollup.old_path.clone()),
173 facts.rollup.old_path.clone(),
174 )
175}
176
177enum CurrentRef<'scan> {
178 Scan(&'scan ChangeFile<'scan>),
179 Empty { source: String, graph: CodeGraph },
180}
181
182struct SidePair<'scan> {
183 old_rel: PathBuf,
184 new_rel: PathBuf,
185 lang: Lang,
186 base_source: String,
187 base_graph: CodeGraph,
188 current: CurrentRef<'scan>,
189 old_hunks: Vec<(u32, u32)>,
190 new_hunks: Vec<(u32, u32)>,
191 file_moved: bool,
192 disposition: FileDisposition,
193}
194
195impl SidePair<'_> {
196 fn base_side(&self) -> FileSide<'_> {
197 FileSide {
198 lang: self.lang,
199 graph: &self.base_graph,
200 source: &self.base_source,
201 file_path: &self.old_rel,
202 }
203 }
204
205 fn current_side(&self) -> FileSide<'_> {
206 match &self.current {
207 CurrentRef::Scan(file) => FileSide {
208 lang: self.lang,
209 graph: file.graph,
210 source: file.source,
211 file_path: &self.new_rel,
212 },
213 CurrentRef::Empty { source, graph } => FileSide {
214 lang: self.lang,
215 graph,
216 source,
217 file_path: &self.new_rel,
218 },
219 }
220 }
221}
222
223fn review_pairs<'scan>(
224 scan: &'scan ChangeScan<'scan>,
225 review_diffs: &ReviewDiffs,
226 review: &mut SemanticReview,
227) -> Vec<SidePair<'scan>> {
228 let diffs = &review_diffs.diffs;
229 let relevant = scan.relevant_diffs(diffs);
230 let matched: FxHashSet<PathBuf> = relevant.by_path.keys().cloned().collect();
231 let deleted: FxHashSet<PathBuf> = relevant
232 .deleted
233 .iter()
234 .map(|diff| normalize_path(&diff_path(diff)))
235 .collect();
236 let rename_origins: FxHashSet<PathBuf> = diffs
237 .iter()
238 .filter_map(|diff| diff.origin.as_ref())
239 .map(|origin| origin.repo_rel.clone())
240 .collect();
241 let mut pairs = Vec::new();
242 for diff in diffs {
243 if diff.status == FileDiffStatus::Deleted && rename_origins.contains(&diff.repo_rel) {
244 continue;
245 }
246 let path = normalize_path(&diff_path(diff));
247 let base_rev = review_diffs.base_rev_for(&diff.repo_root);
248 let outcome = if matched.contains(&path) {
249 scan_file_for(scan, &path)
250 .ok_or_else(|| format!("changed file left the catalog: {}", path.display()))
251 .and_then(|file| matched_pair(scan, diff, file, base_rev))
252 } else if deleted.contains(&path) {
253 deleted_pair(scan, diff, base_rev)
254 } else {
255 Err(String::new())
256 };
257 match outcome {
258 Ok(pair) => pairs.push(pair),
259 Err(message) => {
260 if !message.is_empty() {
261 review.diagnostics.push(message);
262 }
263 review.files.push(opaque_facts(scan, diff));
264 }
265 }
266 }
267 pairs
268}
269
270fn scan_file_for<'scan>(
271 scan: &'scan ChangeScan<'scan>,
272 path: &Path,
273) -> Option<&'scan ChangeFile<'scan>> {
274 scan.files
275 .iter()
276 .find(|file| normalize_path(file.path) == path)
277}
278
279fn matched_pair<'scan>(
280 scan: &ChangeScan<'_>,
281 diff: &FileDiff,
282 file: &'scan ChangeFile<'scan>,
283 base_rev: &str,
284) -> Result<SidePair<'scan>, String> {
285 let (old_hunks, new_hunks) = hunk_spans(&diff.hunks);
286 match diff.status {
287 FileDiffStatus::Added => Ok(SidePair {
288 old_rel: file.rel_path.to_path_buf(),
289 new_rel: file.rel_path.to_path_buf(),
290 lang: file.lang,
291 base_source: String::new(),
292 base_graph: extract_at(scan, file, file.anchor, ""),
293 current: CurrentRef::Scan(file),
294 old_hunks,
295 new_hunks,
296 file_moved: false,
297 disposition: FileDisposition::Added,
298 }),
299 FileDiffStatus::Renamed => renamed_pair(scan, diff, file, (old_hunks, new_hunks), base_rev),
300 _ => {
301 let base_source =
302 git_show(&diff.repo_root, base_rev, &diff.repo_rel).map_err(|error| {
303 format!(
304 "{}: cannot read base blob: {error}",
305 file.rel_path.display()
306 )
307 })?;
308 let base_graph = extract_at(scan, file, file.anchor, &base_source);
309 Ok(SidePair {
310 old_rel: file.rel_path.to_path_buf(),
311 new_rel: file.rel_path.to_path_buf(),
312 lang: file.lang,
313 base_source,
314 base_graph,
315 current: CurrentRef::Scan(file),
316 old_hunks,
317 new_hunks,
318 file_moved: false,
319 disposition: FileDisposition::Modified,
320 })
321 }
322 }
323}
324
325fn renamed_pair<'scan>(
326 scan: &ChangeScan<'_>,
327 diff: &FileDiff,
328 file: &'scan ChangeFile<'scan>,
329 hunks: (LineSpans, LineSpans),
330 base_rev: &str,
331) -> Result<SidePair<'scan>, String> {
332 let origin = diff.origin.as_ref().expect("renamed rows carry an origin");
333 let old_abs = diff.repo_root.join(&origin.repo_rel);
334 let Some((source_root, root, old_rel)) = source_root_for_path(scan, &old_abs) else {
335 return Err(format!(
336 "rename origin {} is outside the scanned source roots",
337 origin.repo_rel.display()
338 ));
339 };
340 let old_anchor = anchor_for(scan, root, &old_rel);
341 let old_display = display_rel_path(scan, source_root, root, &old_rel);
342 let base_source = if origin.score == 100 {
343 file.source.to_string()
344 } else {
345 git_show(&diff.repo_root, base_rev, &origin.repo_rel)
346 .map_err(|error| format!("{}: cannot read base blob: {error}", old_display.display()))?
347 };
348 let base_graph =
349 environment::extract_source_with(file.lang, &base_source, &old_anchor, root.ctx);
350 Ok(SidePair {
351 old_rel: old_display,
352 new_rel: file.rel_path.to_path_buf(),
353 lang: file.lang,
354 base_source,
355 base_graph,
356 current: CurrentRef::Scan(file),
357 old_hunks: hunks.0,
358 new_hunks: hunks.1,
359 file_moved: true,
360 disposition: FileDisposition::Moved { pure: true },
361 })
362}
363
364fn deleted_pair<'scan>(
365 scan: &ChangeScan<'_>,
366 diff: &FileDiff,
367 base_rev: &str,
368) -> Result<SidePair<'scan>, String> {
369 let path = diff_path(diff);
370 let lang = environment::language_for_path(&path).map_err(|error| error.to_string())?;
371 let Some((source_root, root, old_rel)) = source_root_for_path(scan, &path) else {
372 return Err(format!(
373 "deleted file {} is outside the scanned source roots",
374 path.display()
375 ));
376 };
377 let anchor = anchor_for(scan, root, &old_rel);
378 let old_display = display_rel_path(scan, source_root, root, &old_rel);
379 let base_source = git_show(&diff.repo_root, base_rev, &diff.repo_rel)
380 .map_err(|error| format!("{}: cannot read base blob: {error}", old_display.display()))?;
381 let base_graph = environment::extract_source_with(lang, &base_source, &anchor, root.ctx);
382 let empty_graph = environment::extract_source_with(lang, "", &anchor, root.ctx);
383 let (old_hunks, new_hunks) = hunk_spans(&diff.hunks);
384 Ok(SidePair {
385 new_rel: old_display.clone(),
386 old_rel: old_display,
387 lang,
388 base_source,
389 base_graph,
390 current: CurrentRef::Empty {
391 source: String::new(),
392 graph: empty_graph,
393 },
394 old_hunks,
395 new_hunks,
396 file_moved: false,
397 disposition: FileDisposition::Removed,
398 })
399}
400
401fn extract_at(
402 scan: &ChangeScan<'_>,
403 file: &ChangeFile<'_>,
404 anchor: &Path,
405 source: &str,
406) -> CodeGraph {
407 let root = &scan.roots[file.source_root];
408 environment::extract_source_with(file.lang, source, anchor, root.ctx)
409}
410
411fn hunk_spans(hunks: &[DiffHunk]) -> (LineSpans, LineSpans) {
412 let old = hunks
413 .iter()
414 .filter_map(|hunk| hunk.old)
415 .map(|span| (span.start, span.end))
416 .collect();
417 let new = hunks
418 .iter()
419 .filter_map(|hunk| hunk.new)
420 .map(|span| (span.start, span.end))
421 .collect();
422 (old, new)
423}
424
425fn opaque_facts(scan: &ChangeScan<'_>, diff: &FileDiff) -> FileFacts {
426 let path = diff_path(diff);
427 let rel = source_root_for_path(scan, &path)
428 .map(|(source_root, root, rel)| display_rel_path(scan, source_root, root, &rel))
429 .unwrap_or_else(|| diff.repo_rel.clone());
430 let (old_path, new_path, disposition) = match diff.status {
431 FileDiffStatus::Added => (None, Some(rel), FileDisposition::Added),
432 FileDiffStatus::Deleted => (Some(rel), None, FileDisposition::Removed),
433 _ => (Some(rel.clone()), Some(rel), FileDisposition::Modified),
434 };
435 FileFacts {
436 rollup: FileRollup {
437 old_path,
438 new_path,
439 disposition,
440 symbol_changes: 0,
441 moved_symbols: 0,
442 },
443 coverage: HunkCoverage::default(),
444 analyzable: false,
445 }
446}
447
448fn rename_context(changes: &[SymbolChange], pairs: &[SidePair<'_>]) -> RenameContext {
449 let mut ctx = RenameContext::from_changes(changes);
450 for pair in pairs.iter().filter(|pair| pair.file_moved) {
451 ctx.push_pair(
452 pair.base_graph.root().clone(),
453 pair.current_side().graph.root().clone(),
454 );
455 }
456 ctx
457}
458
459fn pair_facts(pair: &SidePair<'_>, changes: &[SymbolChange], refs: &[RefChange]) -> FileFacts {
460 let file_changes: Vec<SymbolChange> = changes
461 .iter()
462 .filter(|change| {
463 change
464 .old
465 .as_ref()
466 .is_some_and(|side| side.file_path == pair.old_rel)
467 || change
468 .new
469 .as_ref()
470 .is_some_and(|side| side.file_path == pair.new_rel)
471 })
472 .cloned()
473 .collect();
474 let coverage = pair_coverage(pair, &file_changes, refs);
475 let mut rollup = match pair.disposition {
476 FileDisposition::Moved { .. } => {
477 moved_file_rollup(pair.old_rel.clone(), pair.new_rel.clone(), &file_changes)
478 }
479 disposition => plain_rollup(pair, disposition, &file_changes),
480 };
481 if rollup.disposition == (FileDisposition::Moved { pure: true }) && !coverage.explained() {
482 rollup.disposition = FileDisposition::Moved { pure: false };
483 }
484 FileFacts {
485 rollup,
486 coverage,
487 analyzable: true,
488 }
489}
490
491fn plain_rollup(
492 pair: &SidePair<'_>,
493 disposition: FileDisposition,
494 file_changes: &[SymbolChange],
495) -> FileRollup {
496 let keep_old = !matches!(disposition, FileDisposition::Added);
497 let keep_new = !matches!(disposition, FileDisposition::Removed);
498 FileRollup {
499 old_path: keep_old.then(|| pair.old_rel.clone()),
500 new_path: keep_new.then(|| pair.new_rel.clone()),
501 disposition,
502 symbol_changes: file_changes.len(),
503 moved_symbols: 0,
504 }
505}
506
507fn pair_coverage(
508 pair: &SidePair<'_>,
509 file_changes: &[SymbolChange],
510 refs: &[RefChange],
511) -> HunkCoverage {
512 let mut old_explained: Vec<(u32, u32)> = Vec::new();
513 let mut new_explained: Vec<(u32, u32)> = Vec::new();
514 for change in file_changes {
515 if let Some(range) = change
516 .old
517 .as_ref()
518 .filter(|side| side.file_path == pair.old_rel)
519 .and_then(|side| side.line_range)
520 {
521 old_explained.push(range);
522 }
523 if let Some(range) = change
524 .new
525 .as_ref()
526 .filter(|side| side.file_path == pair.new_rel)
527 .and_then(|side| side.line_range)
528 {
529 new_explained.push(range);
530 }
531 }
532 for reference in refs {
533 old_explained.extend(reference.old_line_range);
534 new_explained.extend(reference.new_line_range);
535 }
536 hunk_coverage(CoverageInputs {
537 old_hunks: &pair.old_hunks,
538 new_hunks: &pair.new_hunks,
539 old_explained: &old_explained,
540 new_explained: &new_explained,
541 })
542}
543
544#[cfg(test)]
545mod tests {
546 use super::super::super::diff::ChangeRoot;
547 use super::super::model::{RefChangeKind, SemanticKind};
548 use super::*;
549 use crate::environment::ExtractContext;
550 use std::process::Command;
551
552 fn write(root: &Path, rel: &str, body: &str) {
553 let path = root.join(rel);
554 if let Some(parent) = path.parent() {
555 std::fs::create_dir_all(parent).unwrap();
556 }
557 std::fs::write(path, body).unwrap();
558 }
559
560 fn git(root: &Path, args: &[&str]) {
561 let output = Command::new("git")
562 .arg("-C")
563 .arg(root)
564 .args(args)
565 .output()
566 .unwrap_or_else(|e| panic!("cannot run git {args:?}: {e}"));
567 assert!(
568 output.status.success(),
569 "git {args:?} failed\nstdout:\n{}\nstderr:\n{}",
570 String::from_utf8_lossy(&output.stdout),
571 String::from_utf8_lossy(&output.stderr)
572 );
573 }
574
575 struct ScanFixture {
576 root: PathBuf,
577 files: Vec<(PathBuf, String, String)>,
578 graphs: Vec<CodeGraph>,
579 }
580
581 impl ScanFixture {
582 fn new(root: &Path, rels: &[&str]) -> Self {
583 let files: Vec<(PathBuf, String, String)> = rels
584 .iter()
585 .map(|rel| {
586 let path = root.join(rel);
587 let source = std::fs::read_to_string(&path).unwrap();
588 (path, rel.to_string(), source)
589 })
590 .collect();
591 let graphs = files
592 .iter()
593 .map(|(_, rel, source)| {
594 environment::extract_source(Lang::Rs, source, Path::new(rel))
595 })
596 .collect();
597 Self {
598 root: root.to_path_buf(),
599 files,
600 graphs,
601 }
602 }
603
604 fn scan<'a>(&'a self, ctx: &'a ExtractContext) -> ChangeScan<'a> {
605 ChangeScan {
606 roots: vec![ChangeRoot {
607 label: "repo",
608 path: &self.root,
609 ctx,
610 }],
611 files: self
612 .files
613 .iter()
614 .zip(&self.graphs)
615 .enumerate()
616 .map(|(file_idx, ((path, rel, source), graph))| ChangeFile {
617 file_idx,
618 source_root: 0,
619 path,
620 rel_path: Path::new(rel),
621 anchor: Path::new(rel),
622 lang: Lang::Rs,
623 graph,
624 source,
625 })
626 .collect(),
627 }
628 }
629 }
630
631 #[test]
632 fn review_reports_move_edit_retarget_and_opaque_facts_together() {
633 let tmp = tempfile::tempdir().unwrap();
634 git(tmp.path(), &["init"]);
635 git(tmp.path(), &["config", "user.email", "cm@example.test"]);
636 git(tmp.path(), &["config", "user.name", "Code Moniker"]);
637 write(tmp.path(), "Cargo.toml", "[package]\nname = \"demo\"\n");
638 write(tmp.path(), "src/lib.rs", "mod util;\nmod consumer;\n");
639 write(
640 tmp.path(),
641 "src/util.rs",
642 "pub fn assist() { work(); }\npub fn sidekick() { rest(); }\n",
643 );
644 write(
645 tmp.path(),
646 "src/consumer.rs",
647 "use crate::util::assist;\n\npub fn caller() { assist(); }\npub fn edited() -> u32 { 1 }\n",
648 );
649 git(tmp.path(), &["add", "."]);
650 git(tmp.path(), &["commit", "-m", "initial"]);
651 git(tmp.path(), &["mv", "src/util.rs", "src/support.rs"]);
652 write(
653 tmp.path(),
654 "Cargo.toml",
655 "[package]\nname = \"demo\"\nedition = \"2024\"\n",
656 );
657 write(tmp.path(), "src/lib.rs", "mod support;\nmod consumer;\n");
658 write(
659 tmp.path(),
660 "src/consumer.rs",
661 "use crate::support::assist;\n\npub fn caller() { assist(); }\npub fn edited() -> u32 { 2 }\n",
662 );
663 let fixture = ScanFixture::new(
664 tmp.path(),
665 &["src/lib.rs", "src/consumer.rs", "src/support.rs"],
666 );
667 let ctx = ExtractContext::default();
668
669 let review = build_semantic_review(&fixture.scan(&ctx));
670
671 assert!(review.diagnostics.is_empty(), "{:?}", review.diagnostics);
672 let moved = review
673 .files
674 .iter()
675 .find(|facts| facts.rollup.old_path.as_deref() == Some(Path::new("src/util.rs")))
676 .expect("moved file facts");
677 assert_eq!(
678 moved.rollup.new_path.as_deref(),
679 Some(Path::new("src/support.rs"))
680 );
681 assert_eq!(
682 moved.rollup.disposition,
683 FileDisposition::Moved { pure: true },
684 "{moved:?}"
685 );
686 let opaque = review
687 .files
688 .iter()
689 .find(|facts| facts.rollup.new_path.as_deref() == Some(Path::new("Cargo.toml")))
690 .expect("manifest facts");
691 assert!(!opaque.analyzable);
692 let kinds: Vec<SemanticKind> = review
693 .symbol_changes
694 .iter()
695 .map(|change| change.kind)
696 .collect();
697 assert!(kinds.contains(&SemanticKind::BodyModified), "{kinds:?}");
698 assert!(kinds.contains(&SemanticKind::Moved), "{kinds:?}");
699 assert!(
700 !kinds.contains(&SemanticKind::Added) && !kinds.contains(&SemanticKind::Removed),
701 "everything must pair: {:?}",
702 review.symbol_changes
703 );
704 assert!(
705 review
706 .ref_changes
707 .iter()
708 .any(|change| change.kind == RefChangeKind::ImportRetargeted),
709 "{:?}",
710 review.ref_changes
711 );
712 let consumer = review
713 .files
714 .iter()
715 .find(|facts| facts.rollup.new_path.as_deref() == Some(Path::new("src/consumer.rs")))
716 .expect("consumer facts");
717 assert!(
718 consumer.coverage.explained(),
719 "import retarget and body edit must explain every hunk: {consumer:?}"
720 );
721 }
722
723 #[test]
724 fn scoped_review_classifies_a_committed_rename_between_revisions() {
725 let tmp = tempfile::tempdir().unwrap();
726 git(tmp.path(), &["init"]);
727 git(tmp.path(), &["config", "user.email", "cm@example.test"]);
728 git(tmp.path(), &["config", "user.name", "Code Moniker"]);
729 write(
730 tmp.path(),
731 "src/util.rs",
732 "pub fn assist() { work(); }\npub fn sidekick() { rest(); }\n",
733 );
734 git(tmp.path(), &["add", "."]);
735 git(tmp.path(), &["commit", "-m", "initial"]);
736 git(tmp.path(), &["mv", "src/util.rs", "src/support.rs"]);
737 git(tmp.path(), &["commit", "-am", "move"]);
738 let fixture = ScanFixture::new(tmp.path(), &["src/support.rs"]);
739 let ctx = ExtractContext::default();
740 let roots = vec![("repo".to_string(), tmp.path().to_path_buf())];
741
742 for range in ["HEAD~1..HEAD", "HEAD~1...HEAD"] {
743 let scope = DiffScope::parse_range(range).unwrap();
744 let diffs = collect_review_diffs_scoped(&roots, &scope);
745 assert!(diffs.any_root_resolved(), "{:?}", diffs.diagnostics);
746 let review = build_semantic_review_from(&fixture.scan(&ctx), &diffs);
747
748 assert!(review.scope.contains(".."), "{}", review.scope);
749 let moved = review
750 .files
751 .iter()
752 .find(|facts| facts.rollup.old_path.as_deref() == Some(Path::new("src/util.rs")))
753 .unwrap_or_else(|| panic!("moved facts for {range}: {:?}", review.files));
754 assert_eq!(
755 moved.rollup.disposition,
756 FileDisposition::Moved { pure: true },
757 "{range}: {moved:?}"
758 );
759 }
760 }
761
762 #[test]
763 fn scoped_review_reports_unresolvable_revisions() {
764 let tmp = tempfile::tempdir().unwrap();
765 git(tmp.path(), &["init"]);
766 git(tmp.path(), &["config", "user.email", "cm@example.test"]);
767 git(tmp.path(), &["config", "user.name", "Code Moniker"]);
768 write(tmp.path(), "src/lib.rs", "fn lone() {}\n");
769 git(tmp.path(), &["add", "."]);
770 git(tmp.path(), &["commit", "-m", "initial"]);
771 let roots = vec![("repo".to_string(), tmp.path().to_path_buf())];
772
773 let scope = DiffScope::parse_range("no-such-rev..HEAD").unwrap();
774 let diffs = collect_review_diffs_scoped(&roots, &scope);
775
776 assert!(!diffs.any_root_resolved());
777 assert!(
778 diffs
779 .diagnostics
780 .iter()
781 .any(|message| message.contains("no-such-rev")),
782 "{:?}",
783 diffs.diagnostics
784 );
785 }
786
787 #[test]
788 fn review_flags_unattributed_edits_as_residual() {
789 let tmp = tempfile::tempdir().unwrap();
790 git(tmp.path(), &["init"]);
791 git(tmp.path(), &["config", "user.email", "cm@example.test"]);
792 git(tmp.path(), &["config", "user.name", "Code Moniker"]);
793 write(
794 tmp.path(),
795 "src/lib.rs",
796 "fn steady() { work(); }\n// note\n",
797 );
798 git(tmp.path(), &["add", "."]);
799 git(tmp.path(), &["commit", "-m", "initial"]);
800 write(
801 tmp.path(),
802 "src/lib.rs",
803 "fn steady() { work(); }\n// reworded note\n",
804 );
805 let fixture = ScanFixture::new(tmp.path(), &["src/lib.rs"]);
806 let ctx = ExtractContext::default();
807
808 let review = build_semantic_review(&fixture.scan(&ctx));
809
810 let facts = review.files.first().expect("file facts");
811 assert!(
812 !facts.coverage.explained(),
813 "a comment-only edit has no symbolic fact and must stay residual: {facts:?}"
814 );
815 assert!(
816 review.symbol_changes.is_empty(),
817 "{:?}",
818 review.symbol_changes
819 );
820 }
821}