Skip to main content

uasset_lens_git_diff/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::collections::HashSet;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7use uasset_lens_shared::{AssetPath, AssetType};
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct MetricsDelta {
11    pub node_count_delta: i32,
12    pub event_tick_count_delta: i32,
13}
14
15#[derive(Debug, Clone)]
16pub struct AssetDiff {
17    pub asset_path: AssetPath,
18    pub deps_added: Vec<AssetPath>,
19    pub deps_removed: Vec<AssetPath>,
20    pub type_changed: Option<(AssetType, AssetType)>,
21    pub metrics_delta: Option<MetricsDelta>,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum GitDiffError {
26    #[error("io error: {0}")]
27    Io(#[from] std::io::Error),
28}
29
30/// Compare the current on-disk version of `asset_path` against the HEAD-committed version.
31/// Returns `None` when the file is untracked, the repo has no prior commit, or the scan fails.
32pub fn diff_asset(
33    asset_path: &AssetPath,
34    project_dir: &Path,
35    content_root: &Path,
36) -> Result<Option<AssetDiff>, GitDiffError> {
37    let file_path = match find_fs_path(asset_path, content_root) {
38        Some(p) => p,
39        None => return Ok(None),
40    };
41    let git_root = match find_git_root(project_dir) {
42        Some(r) => r,
43        None => return Ok(None),
44    };
45    let relative_str = match file_path.strip_prefix(&git_root) {
46        Ok(rel) => rel.to_string_lossy().replace('\\', "/"), // Windows path → git-style
47        Err(_) => return Ok(None),
48    };
49
50    let output = Command::new("git")
51        .args(["show", &format!("HEAD:{relative_str}")])
52        .current_dir(project_dir)
53        .output()?;
54
55    if !output.status.success() {
56        tracing::debug!(path = %relative_str, "file not in HEAD; skipping diff");
57        return Ok(None);
58    }
59
60    let temp_dir = tempfile::TempDir::new()?;
61    let Some(filename) = file_path.file_name() else {
62        return Ok(None);
63    };
64    let temp_file = temp_dir.path().join(filename);
65    std::fs::write(&temp_file, &output.stdout)?;
66
67    let old_result = uasset_lens_scanner::scan_files(&[temp_file], temp_dir.path());
68    let new_result = uasset_lens_scanner::scan_files(&[file_path], content_root);
69
70    let old_meta = match old_result.assets.into_iter().next() {
71        Some(m) => m,
72        None => return Ok(None),
73    };
74    let new_meta = match new_result.assets.into_iter().next() {
75        Some(m) => m,
76        None => return Ok(None),
77    };
78
79    Ok(Some(compute_diff(
80        asset_path.clone(), // clone required: asset_path is borrowed, compute_diff takes owned AssetPath
81        old_meta,
82        new_meta,
83    )))
84}
85
86fn find_fs_path(asset_path: &AssetPath, content_root: &Path) -> Option<PathBuf> {
87    let relative = asset_path.as_str().strip_prefix("/Game/")?;
88    let mut base = content_root.to_path_buf();
89    for component in relative.split('/') {
90        base.push(component);
91    }
92    for ext in ["uasset", "umap"] {
93        let p = base.with_extension(ext);
94        if p.exists() {
95            return Some(p);
96        }
97    }
98    None
99}
100
101fn find_git_root(project_dir: &Path) -> Option<PathBuf> {
102    let out = Command::new("git")
103        .args(["rev-parse", "--show-toplevel"])
104        .current_dir(project_dir)
105        .output()
106        .ok()?;
107    if !out.status.success() {
108        return None;
109    }
110    let path_str = String::from_utf8(out.stdout).ok()?;
111    Some(PathBuf::from(path_str.trim()))
112}
113
114pub(crate) fn compute_diff(
115    asset_path: AssetPath,
116    old: uasset_lens_scanner::AssetMetadata,
117    new: uasset_lens_scanner::AssetMetadata,
118) -> AssetDiff {
119    let old_deps: HashSet<&AssetPath> = old.dependencies.iter().collect();
120    let new_deps: HashSet<&AssetPath> = new.dependencies.iter().collect();
121
122    let mut deps_added: Vec<AssetPath> = new_deps
123        .difference(&old_deps)
124        .map(|p| (*p).clone()) // clone required: converting &&AssetPath reference to owned
125        .collect();
126    deps_added.sort_by(|a, b| a.as_str().cmp(b.as_str()));
127
128    let mut deps_removed: Vec<AssetPath> = old_deps
129        .difference(&new_deps)
130        .map(|p| (*p).clone()) // clone required: converting &&AssetPath reference to owned
131        .collect();
132    deps_removed.sort_by(|a, b| a.as_str().cmp(b.as_str()));
133
134    let type_changed = if old.asset_type != new.asset_type {
135        Some((old.asset_type, new.asset_type))
136    } else {
137        None
138    };
139
140    let metrics_delta = match (old.blueprint_metrics, new.blueprint_metrics) {
141        (Some(old_m), Some(new_m)) => Some(MetricsDelta {
142            node_count_delta: new_m.node_count as i32 - old_m.node_count as i32,
143            event_tick_count_delta: new_m.event_tick_count as i32 - old_m.event_tick_count as i32,
144        }),
145        _ => None,
146    };
147
148    AssetDiff {
149        asset_path,
150        deps_added,
151        deps_removed,
152        type_changed,
153        metrics_delta,
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use std::path::PathBuf;
161
162    fn make_asset_path(s: &str) -> AssetPath {
163        AssetPath::new(s).unwrap()
164    }
165
166    fn make_meta(
167        asset_path: AssetPath,
168        asset_type: AssetType,
169        dependencies: Vec<AssetPath>,
170        blueprint_metrics: Option<uasset_lens_scanner::BlueprintMetrics>,
171    ) -> uasset_lens_scanner::AssetMetadata {
172        uasset_lens_scanner::AssetMetadata {
173            asset_path,
174            file_path: PathBuf::new(),
175            asset_type,
176            file_size: 0,
177            last_modified: 0,
178            dependencies,
179            soft_dependencies: vec![],
180            blueprint_metrics,
181            material_texture_samples: None,
182        }
183    }
184
185    #[test]
186    fn compute_diff_should_detect_added_and_removed_dependencies() {
187        let path = make_asset_path("/Game/T_Rock");
188        let dep_a = make_asset_path("/Game/A");
189        let dep_b = make_asset_path("/Game/B");
190        let dep_c = make_asset_path("/Game/C");
191
192        let old = make_meta(
193            path.clone(), // clone required: path reused for new meta
194            AssetType::Texture2D,
195            vec![dep_a.clone(), dep_b.clone()], // clone required: deps reused below
196            None,
197        );
198        let new = make_meta(
199            path.clone(), // clone required: path passed to compute_diff separately
200            AssetType::Texture2D,
201            vec![dep_b, dep_c.clone()], // clone required: dep_c checked in assertion
202            None,
203        );
204
205        let diff = compute_diff(path, old, new);
206
207        assert_eq!(diff.deps_added, vec![dep_c]);
208        assert_eq!(diff.deps_removed, vec![dep_a]);
209        assert!(diff.type_changed.is_none());
210        assert!(diff.metrics_delta.is_none());
211    }
212
213    #[test]
214    fn compute_diff_should_detect_type_change() {
215        let path = make_asset_path("/Game/BP_Enemy");
216
217        let old = make_meta(
218            path.clone(), // clone required: path reused
219            AssetType::Blueprint,
220            vec![],
221            None,
222        );
223        let new = make_meta(
224            path.clone(), // clone required: path reused
225            AssetType::StaticMesh,
226            vec![],
227            None,
228        );
229
230        let diff = compute_diff(path, old, new);
231
232        assert_eq!(
233            diff.type_changed,
234            Some((AssetType::Blueprint, AssetType::StaticMesh))
235        );
236    }
237
238    #[test]
239    fn compute_diff_should_compute_node_count_delta_for_blueprint_assets() {
240        let path = make_asset_path("/Game/BP_Enemy");
241
242        let old = make_meta(
243            path.clone(), // clone required: path reused
244            AssetType::Blueprint,
245            vec![],
246            Some(uasset_lens_scanner::BlueprintMetrics {
247                node_count: 3,
248                event_tick_count: 0,
249                cast_count: 0,
250                dependency_depth: 1,
251            }),
252        );
253        let new = make_meta(
254            path.clone(), // clone required: path reused
255            AssetType::Blueprint,
256            vec![],
257            Some(uasset_lens_scanner::BlueprintMetrics {
258                node_count: 5,
259                event_tick_count: 1,
260                cast_count: 0,
261                dependency_depth: 1,
262            }),
263        );
264
265        let diff = compute_diff(path, old, new);
266
267        assert_eq!(
268            diff.metrics_delta,
269            Some(MetricsDelta {
270                node_count_delta: 2,
271                event_tick_count_delta: 1,
272            })
273        );
274    }
275
276    #[test]
277    fn compute_diff_should_return_none_metrics_delta_when_either_asset_lacks_metrics() {
278        let path = make_asset_path("/Game/BP_Enemy");
279
280        let old = make_meta(
281            path.clone(), // clone required: path reused
282            AssetType::Blueprint,
283            vec![],
284            None,
285        );
286        let new = make_meta(
287            path.clone(), // clone required: path reused
288            AssetType::Blueprint,
289            vec![],
290            Some(uasset_lens_scanner::BlueprintMetrics {
291                node_count: 5,
292                event_tick_count: 1,
293                cast_count: 0,
294                dependency_depth: 1,
295            }),
296        );
297
298        let diff = compute_diff(path, old, new);
299
300        assert!(diff.metrics_delta.is_none());
301    }
302}