Skip to main content

perfgate_domain/
blame.rs

1//! Binary delta blame logic for perfgate.
2//!
3//! This module provides functions to analyze changes in Cargo.lock
4//! and map them to observed changes in binary_bytes.
5
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, HashSet};
9
10/// Information about a dependency change.
11#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
12pub struct DependencyChange {
13    pub name: String,
14    pub old_version: Option<String>,
15    pub new_version: Option<String>,
16    pub change_type: DependencyChangeType,
17}
18
19#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
20#[serde(rename_all = "snake_case")]
21pub enum DependencyChangeType {
22    Added,
23    Removed,
24    Updated,
25}
26
27/// Result of a binary blame analysis.
28#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
29pub struct BinaryBlame {
30    pub changes: Vec<DependencyChange>,
31}
32
33/// Parses a Cargo.lock string and returns a map of package name to version.
34pub fn parse_lockfile(content: &str) -> BTreeMap<String, String> {
35    let mut packages = BTreeMap::new();
36    let mut current_package = None;
37
38    for line in content.lines() {
39        let line = line.trim();
40        if line == "[[package]]" {
41            current_package = None;
42        } else if line.starts_with("name = ") {
43            current_package = line
44                .strip_prefix("name = ")
45                .map(|s| s.trim_matches('"').to_string());
46        } else if line.starts_with("version = ")
47            && let (Some(name), Some(version)) = (
48                current_package.as_ref(),
49                line.strip_prefix("version = ").map(|s| s.trim_matches('"')),
50            )
51        {
52            packages.insert(name.clone(), version.to_string());
53        }
54    }
55    packages
56}
57
58/// Compares two lockfiles and returns the differences.
59pub fn compare_lockfiles(old_lock: &str, new_lock: &str) -> BinaryBlame {
60    let old_pkgs = parse_lockfile(old_lock);
61    let new_pkgs = parse_lockfile(new_lock);
62
63    let mut changes = Vec::new();
64    let all_names: HashSet<_> = old_pkgs.keys().chain(new_pkgs.keys()).collect();
65
66    for name in all_names {
67        match (old_pkgs.get(name), new_pkgs.get(name)) {
68            (Some(old_v), Some(new_v)) if old_v != new_v => {
69                changes.push(DependencyChange {
70                    name: name.clone(),
71                    old_version: Some(old_v.clone()),
72                    new_version: Some(new_v.clone()),
73                    change_type: DependencyChangeType::Updated,
74                });
75            }
76            (None, Some(new_v)) => {
77                changes.push(DependencyChange {
78                    name: name.clone(),
79                    old_version: None,
80                    new_version: Some(new_v.clone()),
81                    change_type: DependencyChangeType::Added,
82                });
83            }
84            (Some(old_v), None) => {
85                changes.push(DependencyChange {
86                    name: name.clone(),
87                    old_version: Some(old_v.clone()),
88                    new_version: None,
89                    change_type: DependencyChangeType::Removed,
90                });
91            }
92            _ => {}
93        }
94    }
95
96    // Sort by name for deterministic output
97    changes.sort_by(|a, b| a.name.cmp(&b.name));
98
99    BinaryBlame { changes }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_parse_lockfile() {
108        let lock = r#"
109[[package]]
110name = "pkg1"
111version = "1.0.0"
112
113[[package]]
114name = "pkg2"
115version = "2.1.0"
116"#;
117        let pkgs = parse_lockfile(lock);
118        assert_eq!(pkgs.len(), 2);
119        assert_eq!(pkgs["pkg1"], "1.0.0");
120        assert_eq!(pkgs["pkg2"], "2.1.0");
121    }
122
123    #[test]
124    fn test_compare_lockfiles() {
125        let old = r#"
126[[package]]
127name = "stay"
128version = "1.0.0"
129[[package]]
130name = "update"
131version = "1.0.0"
132[[package]]
133name = "remove"
134version = "1.0.0"
135"#;
136        let new = r#"
137[[package]]
138name = "stay"
139version = "1.0.0"
140[[package]]
141name = "update"
142version = "1.1.0"
143[[package]]
144name = "add"
145version = "1.0.0"
146"#;
147        let blame = compare_lockfiles(old, new);
148        assert_eq!(blame.changes.len(), 3);
149
150        assert_eq!(blame.changes[0].name, "add");
151        assert_eq!(blame.changes[0].change_type, DependencyChangeType::Added);
152
153        assert_eq!(blame.changes[1].name, "remove");
154        assert_eq!(blame.changes[1].change_type, DependencyChangeType::Removed);
155
156        assert_eq!(blame.changes[2].name, "update");
157        assert_eq!(blame.changes[2].change_type, DependencyChangeType::Updated);
158    }
159}