1use crate::catalog;
2use anyhow::{Result, ensure};
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::BTreeMap,
6 fs,
7 io::Read,
8 path::{Path, PathBuf},
9};
10use walkdir::WalkDir;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Resource {
14 pub path: PathBuf,
15 pub bytes: u64,
16 pub kind: String,
17 pub format: String,
18 pub extension: String,
19 pub extension_mismatch: bool,
20 pub origin: String,
21 pub conversion_exclusion: Option<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub format_lock: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub android: Option<crate::android::AndroidResource>,
30 #[serde(default)]
32 pub support: String,
33}
34
35impl Resource {
36 #[cfg(test)]
37 pub(crate) fn for_tests(path: &str, format: &str) -> Self {
38 Self {
39 path: path.into(),
40 bytes: 0,
41 kind: kind(format).into(),
42 format: format.into(),
43 extension: format.into(),
44 extension_mismatch: false,
45 origin: "loose_file".into(),
46 conversion_exclusion: None,
47 format_lock: None,
48 android: None,
49 support: "optimizable".into(),
50 }
51 }
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55pub struct ResourceInventory {
56 pub schema_version: u32,
57 pub root: PathBuf,
58 pub catalogs: usize,
59 pub assets: Vec<Resource>,
60 pub skipped_source_or_tooling_files: usize,
61 pub excluded_directories: Vec<PathBuf>,
62 pub diagnostics: Vec<String>,
63 #[serde(default)]
65 pub project_kinds: Vec<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub android_min_sdk: Option<crate::android_project::MinSdk>,
69}
70
71pub fn inventory(root: impl AsRef<Path>) -> Result<ResourceInventory> {
74 inventory_with_options(root, crate::ScanOptions::default())
75}
76
77pub fn inventory_with_options(
78 root: impl AsRef<Path>,
79 options: crate::ScanOptions,
80) -> Result<ResourceInventory> {
81 let root = fs::canonicalize(root)?;
82 let filter = crate::scan_options::ScanFilter::new(&root, options)?;
83 let catalogs = catalog::scan_filtered(&root, &filter)?;
84 let references: BTreeMap<_, _> = catalogs
85 .assets
86 .into_iter()
87 .map(|a| (a.path.clone(), a))
88 .collect();
89 let mut report = ResourceInventory {
90 schema_version: 3,
91 root: catalogs.root,
92 catalogs: catalogs.catalogs,
93 assets: vec![],
94 skipped_source_or_tooling_files: 0,
95 excluded_directories: vec![],
96 diagnostics: catalogs.diagnostics,
97 project_kinds: vec![],
98 android_min_sdk: None,
99 };
100 let mut walk = WalkDir::new(&report.root).follow_links(false).into_iter();
101 while let Some(entry) = walk.next() {
102 let entry = match entry {
103 Ok(entry) => entry,
104 Err(error) => {
105 report.diagnostics.push(error.to_string());
106 continue;
107 }
108 };
109 let relative = entry.path().strip_prefix(&report.root)?.to_path_buf();
110 if !filter.allows(entry.path()) {
111 if entry.file_type().is_dir() {
112 report.excluded_directories.push(relative);
113 walk.skip_current_dir();
114 }
115 continue;
116 }
117 if entry.file_type().is_symlink() {
118 report
119 .diagnostics
120 .push(format!("symlink_skipped: {}", relative.display()));
121 continue;
122 }
123 if entry.file_type().is_dir() {
124 let name = entry.file_name().to_string_lossy();
127 if entry.depth() > 0
128 && ((catalog::excluded(&name)
129 && !matches!(&*name, "Pods" | "Carthage" | "node_modules"))
130 || matches!(
131 &*name,
132 ".github" | ".codex" | ".claude" | ".agents" | ".idea" | ".vscode"
133 ))
134 {
135 report.excluded_directories.push(relative);
136 walk.skip_current_dir();
137 }
138 continue;
139 }
140 if !entry.file_type().is_file() {
141 continue;
142 }
143 let extension = entry
144 .path()
145 .extension()
146 .and_then(|e| e.to_str())
147 .unwrap_or("")
148 .to_ascii_lowercase();
149 let in_catalog = relative.components().any(|part| {
150 Path::new(part.as_os_str())
151 .extension()
152 .is_some_and(|e| e == "xcassets")
153 });
154 let in_resource_directory = relative.components().any(|part| {
155 part.as_os_str().to_str().is_some_and(|name| {
156 matches!(
157 name.to_ascii_lowercase().as_str(),
158 "resources" | "resource" | "assets" | "res"
159 )
160 })
161 });
162 if !in_catalog
163 && !in_resource_directory
164 && is_source_or_tooling(entry.file_name().to_str().unwrap_or(""), &extension)
165 {
166 report.skipped_source_or_tooling_files += 1;
167 continue;
168 }
169 let mut header = [0_u8; 512];
170 let read = match fs::File::open(entry.path()).and_then(|mut f| f.read(&mut header)) {
171 Ok(n) => n,
172 Err(error) => {
173 report
174 .diagnostics
175 .push(format!("{}: {error}", relative.display()));
176 continue;
177 }
178 };
179 let format = actual_format(&header[..read])
180 .unwrap_or_else(|| extension_format(&extension))
181 .to_string();
182 let kind = kind(&format).to_string();
183 let extension_mismatch =
184 matches!(kind.as_str(), "image") && extension_format(&extension) != format;
185 let referenced = references.get(&relative);
186 let origin = if referenced.is_some() {
187 "catalog_rendition"
188 } else if in_catalog {
189 "catalog_file"
190 } else {
191 "loose_file"
192 }
193 .to_string();
194 let android = crate::android::classify(&relative);
195 let conversion_exclusion = if relative.components().any(|p| {
196 Path::new(p.as_os_str())
197 .extension()
198 .is_some_and(|e| e == "appiconset")
199 }) {
200 Some("app_icon".into())
201 } else {
202 referenced
203 .and_then(|a| a.reason.as_ref())
204 .filter(|r| r.as_str() == "resizing")
205 .cloned()
206 };
207 let format_lock = android
208 .as_ref()
209 .and_then(|a| a.format_lock())
210 .map(str::to_string);
211 let support = if conversion_exclusion.is_some() {
212 "excluded"
213 } else if crate::capabilities::can_optimize(&kind, &format) {
214 "optimizable"
215 } else {
216 "unsupported"
217 }
218 .to_string();
219 report.assets.push(Resource {
220 path: relative,
221 bytes: entry.metadata()?.len(),
222 kind,
223 format,
224 extension,
225 extension_mismatch,
226 origin,
227 conversion_exclusion,
228 format_lock,
229 android,
230 support,
231 });
232 }
233 report.project_kinds = project_kinds(&filter, &report);
234 if report.project_kinds.iter().any(|kind| kind == "android") {
235 report.android_min_sdk = crate::android_project::detect_min_sdk(&report.root, &filter);
236 }
237 report.assets.sort_by(|a, b| a.path.cmp(&b.path));
238 report.excluded_directories.sort();
239 report.diagnostics.sort();
240 Ok(report)
241}
242
243pub(crate) fn actual_format(bytes: &[u8]) -> Option<&'static str> {
244 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
245 return Some("png");
246 }
247 if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
248 return Some("jpeg");
249 }
250 if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
251 return Some("webp");
252 }
253 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
254 return Some("gif");
255 }
256 if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
257 return Some("tiff");
258 }
259 if bytes.starts_with(b"BM") {
260 return Some("bmp");
261 }
262 if bytes.starts_with(b"%PDF-") {
263 return Some("pdf");
264 }
265 if bytes.get(4..8) == Some(b"ftyp") && bytes.len() >= 16 {
266 let size = u32::from_be_bytes(bytes[..4].try_into().ok()?) as usize;
267 if size < 16 {
268 return None;
269 }
270 let brands = &bytes[8..size.min(bytes.len())];
271 if brands
272 .as_chunks::<4>()
273 .0
274 .iter()
275 .any(|b| matches!(b, b"avif" | b"avis"))
276 {
277 return Some("avif");
278 }
279 if brands
280 .as_chunks::<4>()
281 .0
282 .iter()
283 .any(|b| matches!(b, b"heic" | b"heix" | b"hevc" | b"hevx"))
284 {
285 return Some("heic");
286 }
287 if brands
288 .as_chunks::<4>()
289 .0
290 .iter()
291 .any(|b| matches!(b, b"mif1" | b"msf1"))
292 {
293 return Some("heif");
294 }
295 }
296 None
297}
298
299fn extension_format(extension: &str) -> &str {
300 match extension {
301 "jpg" | "jpe" => "jpeg",
302 "tif" => "tiff",
303 "heics" => "heic",
304 "" => "unknown",
305 other => other,
306 }
307}
308
309fn kind(format: &str) -> &'static str {
310 match format {
311 "png" | "jpeg" | "heic" | "heif" | "webp" | "gif" | "tiff" | "bmp" | "avif" | "jxl"
312 | "ico" | "icns" | "psd" => "image",
313 "svg" | "pdf" => "vector",
314 "mp4" | "mov" | "m4v" | "webm" | "avi" => "video",
315 "mp3" | "m4a" | "aac" | "wav" | "ogg" | "caf" | "flac" | "aiff" => "audio",
316 "svga" | "vap" | "tcmp4" | "lottie" | "pag" => "animation",
317 "ttf" | "otf" | "woff" | "woff2" => "font",
318 "zip" | "gz" | "br" | "7z" | "rar" | "tar" => "archive",
319 "xcstrings" | "strings" | "stringsdict" => "localization",
320 "json" | "yaml" | "yml" | "toml" | "xml" | "plist" | "html" | "css" | "js" | "bin"
321 | "dat" | "txt" | "csv" | "db" | "sqlite" => "data",
322 _ => "unclassified",
323 }
324}
325
326fn is_source_or_tooling(name: &str, extension: &str) -> bool {
327 name.starts_with('.')
328 || matches!(
329 name,
330 "LICENSE" | "Makefile" | "Podfile" | "Gemfile" | "Rakefile"
331 )
332 || matches!(
333 extension,
334 "swift"
335 | "rs"
336 | "m"
337 | "mm"
338 | "h"
339 | "c"
340 | "cc"
341 | "cpp"
342 | "hpp"
343 | "kt"
344 | "java"
345 | "py"
346 | "pyc"
347 | "pyo"
348 | "sh"
349 | "rb"
350 | "toml"
351 | "lock"
352 | "md"
353 | "yml"
354 | "yaml"
355 | "pbxproj"
356 | "xcscheme"
357 | "xcworkspacedata"
358 | "xcuserstate"
359 | "xcconfig"
360 | "entitlements"
361 | "resolved"
362 )
363}
364
365pub(crate) fn bounded_read(path: &Path) -> Result<Vec<u8>> {
366 let mut bytes = Vec::new();
367 fs::File::open(path)?
368 .take(64 * 1024 * 1024 + 1)
369 .read_to_end(&mut bytes)?;
370 ensure!(bytes.len() <= 64 * 1024 * 1024, "input_exceeds_64_mib");
371 Ok(bytes)
372}
373
374fn project_kinds(
376 filter: &crate::scan_options::ScanFilter,
377 report: &ResourceInventory,
378) -> Vec<String> {
379 let mut kinds = std::collections::BTreeSet::new();
380 for path in filter.paths() {
381 let name = path.file_name().unwrap_or_default().to_string_lossy();
382 if name.ends_with(".xcodeproj") || name.ends_with(".xcworkspace") {
383 kinds.insert("xcode");
384 } else if name == "Package.swift" {
385 kinds.insert("swift_package");
386 } else if name == "AndroidManifest.xml" {
387 kinds.insert("android");
388 }
389 }
390 if report.catalogs > 0 && !kinds.contains("swift_package") {
391 kinds.insert("xcode");
392 }
393 if report.assets.iter().any(|a| a.android.is_some()) {
394 kinds.insert("android");
395 }
396 if kinds.is_empty() {
397 kinds.insert("directory");
398 }
399 kinds.into_iter().map(str::to_string).collect()
400}