Skip to main content

code_moniker_workspace/changes/semantic/
review.rs

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}