Skip to main content

cargo_coupling/design/
changes.rs

1//! Read-only Git changes, source ranges, and reproducible run identities.
2
3use 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}