1use super::source::{SourceInventory, SourceItem, fingerprint};
4use serde::Serialize;
5use std::{
6 collections::BTreeMap,
7 fs,
8 path::{Path, PathBuf},
9 process::Command,
10};
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ChangedFile {
14 pub path: String,
15 pub previous_path: Option<String>,
16 pub status: String,
17 pub ranges: Vec<ChangedRange>,
18 pub items: Vec<SourceItem>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22pub struct ChangedRange {
23 pub old_start: usize,
24 pub old_lines: usize,
25 pub new_start: usize,
26 pub new_lines: usize,
27}
28
29#[derive(Debug, Clone, Default, Serialize)]
30pub struct ChangeSet {
31 pub baseline: Option<String>,
32 pub files: Vec<ChangedFile>,
33 pub notes: Vec<String>,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct Provenance {
38 pub analyzer_version: String,
39 pub scoring_version: String,
40 pub scope: String,
41 pub revision: Option<String>,
42 pub dirty: Option<bool>,
43 pub source_fingerprint: String,
44 pub config_fingerprint: Option<String>,
45 pub effective_settings: crate::config::CouplingConfig,
46 pub context_fingerprint: String,
47 pub git_months: usize,
48 pub git_used: bool,
49 pub tests_excluded: bool,
50 pub max_depth: Option<usize>,
51 pub thresholds: BTreeMap<String, usize>,
52}
53
54pub fn git_root(path: &Path) -> Option<PathBuf> {
55 let start = if path.is_file() { path.parent()? } else { path };
56 let output = git(start, &["rev-parse", "--show-toplevel"]).ok()?;
57 Some(PathBuf::from(output.trim()))
58}
59
60pub fn git(root: &Path, args: &[&str]) -> Result<String, std::io::Error> {
61 let output = Command::new("git")
62 .arg("--literal-pathspecs")
63 .arg("-C")
64 .arg(root)
65 .args(args)
66 .output()?;
67 if !output.status.success() {
68 return Err(std::io::Error::other(
69 String::from_utf8_lossy(&output.stderr).trim().to_string(),
70 ));
71 }
72 String::from_utf8(output.stdout).map_err(std::io::Error::other)
73}
74
75pub fn resolve_ref(root: &Path, reference: &str) -> Result<String, std::io::Error> {
76 let revision = format!("{reference}^{{commit}}");
77 Ok(git(
78 root,
79 &["rev-parse", "--verify", "--end-of-options", &revision],
80 )?
81 .trim()
82 .to_string())
83}
84
85pub fn read_changes(
86 path: &Path,
87 reference: &str,
88 inventory: &SourceInventory,
89 source_paths: &[PathBuf],
90) -> Result<ChangeSet, std::io::Error> {
91 let root = git_root(path)
92 .ok_or_else(|| std::io::Error::other("--changed-since requires a Git repository"))?;
93 let baseline = resolve_ref(&root, reference)?;
94 let mut selected = fs::canonicalize(path)?;
95 if selected
96 .file_name()
97 .is_some_and(|name| name == "Cargo.toml")
98 {
99 selected.pop();
100 }
101 let scope = selected
102 .strip_prefix(&root)
103 .map_err(std::io::Error::other)?
104 .to_string_lossy();
105 let scope = if scope.is_empty() { "." } else { &scope };
106 let mut pathspecs = vec![scope.to_string()];
107 for source in source_paths
108 .iter()
109 .filter(|source| !source.starts_with(&selected))
110 {
111 if let Ok(relative) = source.strip_prefix(&root) {
112 pathspecs.push(relative.to_string_lossy().to_string());
113 }
114 }
115 pathspecs.sort();
116 pathspecs.dedup();
117 let mut status_args = vec![
118 "diff",
119 "--no-ext-diff",
120 "--no-textconv",
121 "--name-status",
122 "-z",
123 "--find-renames",
124 &baseline,
125 "--",
126 ];
127 status_args.extend(pathspecs.iter().map(String::as_str));
128 let status = git(&root, &status_args)?;
129 let mut names = status.split('\0').filter(|part| !part.is_empty());
130 let mut files = Vec::new();
131 while let Some(code) = names.next() {
132 let first = names
133 .next()
134 .ok_or_else(|| std::io::Error::other("invalid Git name-status output"))?;
135 let renamed = code.starts_with('R') || code.starts_with('C');
136 let filename = if renamed {
137 names
138 .next()
139 .ok_or_else(|| std::io::Error::other("invalid Git rename output"))?
140 } else {
141 first
142 };
143 let previous = renamed.then(|| first.to_string());
144 let patch = git(
145 &root,
146 &[
147 "diff",
148 "--no-ext-diff",
149 "--no-textconv",
150 "--no-color",
151 "--unified=0",
152 &baseline,
153 "--",
154 filename,
155 ],
156 )?;
157 let ranges = parse_ranges(&patch);
158 let absolute = root.join(filename).display().to_string();
159 let mut items: Vec<_> = inventory
160 .items
161 .iter()
162 .filter(|item| {
163 item.file_path == absolute
164 && ranges
165 .iter()
166 .any(|range| overlaps(item, range.new_start, range.new_lines))
167 })
168 .cloned()
169 .collect();
170 if renamed && ranges.is_empty() {
171 items = inventory
172 .items
173 .iter()
174 .filter(|item| item.file_path == absolute)
175 .cloned()
176 .collect();
177 }
178 if code.starts_with('D') || ranges.iter().any(|range| range.old_lines > 0) {
179 let blob = git(&root, &["show", &format!("{baseline}:{first}")])?;
180 let module = inventory
181 .items
182 .iter()
183 .find(|item| item.file_path == absolute)
184 .map(|item| item.module.clone())
185 .unwrap_or_else(|| {
186 Path::new(first)
187 .file_stem()
188 .unwrap_or_default()
189 .to_string_lossy()
190 .to_string()
191 });
192 let old_items =
193 SourceInventory::from_source(&blob, &root.join(first), &module, false).items;
194 for mut item in old_items {
195 if (code.starts_with('D')
196 || ranges
197 .iter()
198 .any(|range| overlaps(&item, range.old_start, range.old_lines)))
199 && !items
200 .iter()
201 .any(|current| current.module == item.module && current.name == item.name)
202 {
203 item.revision = Some(baseline.clone());
204 items.push(item);
205 }
206 }
207 }
208 files.push(ChangedFile {
209 path: filename.into(),
210 previous_path: previous,
211 status: code.into(),
212 ranges,
213 items,
214 });
215 }
216 let mut untracked_args = vec!["ls-files", "--others", "--exclude-standard", "-z", "--"];
217 untracked_args.extend(pathspecs.iter().map(String::as_str));
218 let untracked = git(&root, &untracked_args)?;
219 for filename in untracked.split('\0').filter(|part| !part.is_empty()) {
220 let absolute = root.join(filename).display().to_string();
221 let items = inventory
222 .items
223 .iter()
224 .filter(|item| item.file_path == absolute)
225 .cloned()
226 .collect();
227 files.push(ChangedFile {
228 path: filename.into(),
229 previous_path: None,
230 status: "?".into(),
231 ranges: vec![],
232 items,
233 });
234 }
235 files.sort_by(|a, b| a.path.cmp(&b.path));
236 Ok(ChangeSet { baseline: Some(baseline), files, notes: vec!["Changes include staged, unstaged and untracked paths; item ranges come from Rust source spans. Deleted items need baseline dependencies for complete impact.".into()] })
237}
238
239fn overlaps(item: &SourceItem, start: usize, length: usize) -> bool {
240 if length == 0 {
241 return false;
242 }
243 let end = start.saturating_add(length.saturating_sub(1));
244 item.line <= end && item.end_line >= start
245}
246
247fn parse_ranges(patch: &str) -> Vec<ChangedRange> {
248 patch
249 .lines()
250 .filter_map(|line| {
251 if !line.starts_with("@@ ") {
252 return None;
253 }
254 let mut fields = line.split_whitespace().skip(1);
255 let (old_start, old_lines) = parse_range(fields.next()?.strip_prefix('-')?)?;
256 let (new_start, new_lines) = parse_range(fields.next()?.strip_prefix('+')?)?;
257 Some(ChangedRange {
258 old_start,
259 old_lines,
260 new_start,
261 new_lines,
262 })
263 })
264 .collect()
265}
266
267fn parse_range(range: &str) -> Option<(usize, usize)> {
268 match range.split_once(',') {
269 Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
270 None => Some((range.parse().ok()?, 1)),
271 }
272}
273
274pub fn source_fingerprint(inventory: &SourceInventory, root: &Path) -> String {
275 let source: String = inventory
276 .fingerprints
277 .iter()
278 .map(|(path, hash)| {
279 let relative = Path::new(path)
280 .strip_prefix(root)
281 .unwrap_or(Path::new(path));
282 format!("{}\0{hash}\n", relative.display())
283 })
284 .collect();
285 fingerprint(source.as_bytes())
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 #[test]
292 fn deleted_items_in_surviving_file_are_included_with_manifest_scope() {
293 let dir = tempfile::tempdir().unwrap();
294 fs::write(
295 dir.path().join("Cargo.toml"),
296 "[package]\nname='fixture'\nversion='0.1.0'\n",
297 )
298 .unwrap();
299 let file = dir.path().join("lib.rs");
300 fs::write(&file, "pub fn removed() {}\npub fn retained() {}\n").unwrap();
301 git(dir.path(), &["init", "-q"]).unwrap();
302 git(dir.path(), &["add", "."]).unwrap();
303 git(
304 dir.path(),
305 &[
306 "-c",
307 "user.name=Fixture",
308 "-c",
309 "user.email=fixture@example.invalid",
310 "commit",
311 "-qm",
312 "baseline",
313 ],
314 )
315 .unwrap();
316 let source = "pub fn retained() {}\n";
317 fs::write(&file, source).unwrap();
318 let inventory = SourceInventory::from_source(source, &file, "lib", false);
319 let changes =
320 read_changes(&dir.path().join("Cargo.toml"), "HEAD", &inventory, &[]).unwrap();
321 assert!(
322 changes
323 .files
324 .iter()
325 .flat_map(|file| &file.items)
326 .any(|item| item.name == "removed"
327 && item.revision.as_deref() == changes.baseline.as_deref())
328 );
329 }
330 #[test]
331 fn parses_additions_and_deletions_without_count_defaults() {
332 let ranges = parse_ranges("@@ -2,0 +3,4 @@\n+x\n@@ -8 +12,0 @@\n-y\n");
333 assert_eq!(ranges.len(), 2);
334 assert_eq!(ranges[0].old_lines, 0);
335 assert_eq!(ranges[1].old_lines, 1);
336 assert_eq!(ranges[1].new_lines, 0);
337 }
338}