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
30pub 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('\\', "/"), 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(), 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()) .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()) .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(), AssetType::Texture2D,
195 vec![dep_a.clone(), dep_b.clone()], None,
197 );
198 let new = make_meta(
199 path.clone(), AssetType::Texture2D,
201 vec![dep_b, dep_c.clone()], 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(), AssetType::Blueprint,
220 vec![],
221 None,
222 );
223 let new = make_meta(
224 path.clone(), 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(), 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(), 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(), AssetType::Blueprint,
283 vec![],
284 None,
285 );
286 let new = make_meta(
287 path.clone(), 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}