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