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>,
22}
23
24#[derive(Debug, Serialize, Deserialize)]
25pub struct ResourceInventory {
26 pub schema_version: u32,
27 pub root: PathBuf,
28 pub catalogs: usize,
29 pub assets: Vec<Resource>,
30 pub skipped_source_or_tooling_files: usize,
31 pub excluded_directories: Vec<PathBuf>,
32 pub diagnostics: Vec<String>,
33}
34
35pub fn inventory(root: impl AsRef<Path>) -> Result<ResourceInventory> {
38 inventory_with_options(root, crate::ScanOptions::default())
39}
40
41pub fn inventory_with_options(
42 root: impl AsRef<Path>,
43 options: crate::ScanOptions,
44) -> Result<ResourceInventory> {
45 let root = fs::canonicalize(root)?;
46 let filter = crate::scan_options::ScanFilter::new(&root, options)?;
47 let catalogs = catalog::scan_filtered(&root, &filter)?;
48 let references: BTreeMap<_, _> = catalogs
49 .assets
50 .into_iter()
51 .map(|a| (a.path.clone(), a))
52 .collect();
53 let mut report = ResourceInventory {
54 schema_version: 2,
55 root: catalogs.root,
56 catalogs: catalogs.catalogs,
57 assets: vec![],
58 skipped_source_or_tooling_files: 0,
59 excluded_directories: vec![],
60 diagnostics: catalogs.diagnostics,
61 };
62 let mut walk = WalkDir::new(&report.root).follow_links(false).into_iter();
63 while let Some(entry) = walk.next() {
64 let entry = match entry {
65 Ok(entry) => entry,
66 Err(error) => {
67 report.diagnostics.push(error.to_string());
68 continue;
69 }
70 };
71 let relative = entry.path().strip_prefix(&report.root)?.to_path_buf();
72 if !filter.allows(entry.path()) {
73 if entry.file_type().is_dir() {
74 report.excluded_directories.push(relative);
75 walk.skip_current_dir();
76 }
77 continue;
78 }
79 if entry.file_type().is_symlink() {
80 report
81 .diagnostics
82 .push(format!("symlink_skipped: {}", relative.display()));
83 continue;
84 }
85 if entry.file_type().is_dir() {
86 let name = entry.file_name().to_string_lossy();
89 if entry.depth() > 0
90 && ((catalog::excluded(&name)
91 && !matches!(&*name, "Pods" | "Carthage" | "node_modules"))
92 || matches!(
93 &*name,
94 ".github" | ".codex" | ".claude" | ".agents" | ".idea" | ".vscode"
95 ))
96 {
97 report.excluded_directories.push(relative);
98 walk.skip_current_dir();
99 }
100 continue;
101 }
102 if !entry.file_type().is_file() {
103 continue;
104 }
105 let extension = entry
106 .path()
107 .extension()
108 .and_then(|e| e.to_str())
109 .unwrap_or("")
110 .to_ascii_lowercase();
111 let in_catalog = relative.components().any(|part| {
112 Path::new(part.as_os_str())
113 .extension()
114 .is_some_and(|e| e == "xcassets")
115 });
116 let in_resource_directory = relative.components().any(|part| {
117 part.as_os_str().to_str().is_some_and(|name| {
118 matches!(
119 name.to_ascii_lowercase().as_str(),
120 "resources" | "resource" | "assets" | "res"
121 )
122 })
123 });
124 if !in_catalog
125 && !in_resource_directory
126 && is_source_or_tooling(entry.file_name().to_str().unwrap_or(""), &extension)
127 {
128 report.skipped_source_or_tooling_files += 1;
129 continue;
130 }
131 let mut header = [0_u8; 512];
132 let read = match fs::File::open(entry.path()).and_then(|mut f| f.read(&mut header)) {
133 Ok(n) => n,
134 Err(error) => {
135 report
136 .diagnostics
137 .push(format!("{}: {error}", relative.display()));
138 continue;
139 }
140 };
141 let format = actual_format(&header[..read])
142 .unwrap_or_else(|| extension_format(&extension))
143 .to_string();
144 let kind = kind(&format).to_string();
145 let extension_mismatch =
146 matches!(kind.as_str(), "image") && extension_format(&extension) != format;
147 let referenced = references.get(&relative);
148 let origin = if referenced.is_some() {
149 "catalog_rendition"
150 } else if in_catalog {
151 "catalog_file"
152 } else {
153 "loose_file"
154 }
155 .to_string();
156 let conversion_exclusion = if relative.components().any(|p| {
157 Path::new(p.as_os_str())
158 .extension()
159 .is_some_and(|e| e == "appiconset")
160 }) {
161 Some("app_icon".into())
162 } else {
163 referenced
164 .and_then(|a| a.reason.as_ref())
165 .filter(|r| r.as_str() == "resizing")
166 .cloned()
167 };
168 report.assets.push(Resource {
169 path: relative,
170 bytes: entry.metadata()?.len(),
171 kind,
172 format,
173 extension,
174 extension_mismatch,
175 origin,
176 conversion_exclusion,
177 });
178 }
179 report.assets.sort_by(|a, b| a.path.cmp(&b.path));
180 report.excluded_directories.sort();
181 report.diagnostics.sort();
182 Ok(report)
183}
184
185pub(crate) fn actual_format(bytes: &[u8]) -> Option<&'static str> {
186 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
187 return Some("png");
188 }
189 if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
190 return Some("jpeg");
191 }
192 if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
193 return Some("webp");
194 }
195 if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
196 return Some("gif");
197 }
198 if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
199 return Some("tiff");
200 }
201 if bytes.starts_with(b"BM") {
202 return Some("bmp");
203 }
204 if bytes.starts_with(b"%PDF-") {
205 return Some("pdf");
206 }
207 if bytes.get(4..8) == Some(b"ftyp") && bytes.len() >= 16 {
208 let size = u32::from_be_bytes(bytes[..4].try_into().ok()?) as usize;
209 if size < 16 {
210 return None;
211 }
212 let brands = &bytes[8..size.min(bytes.len())];
213 if brands
214 .as_chunks::<4>()
215 .0
216 .iter()
217 .any(|b| matches!(b, b"avif" | b"avis"))
218 {
219 return Some("avif");
220 }
221 if brands
222 .as_chunks::<4>()
223 .0
224 .iter()
225 .any(|b| matches!(b, b"heic" | b"heix" | b"hevc" | b"hevx"))
226 {
227 return Some("heic");
228 }
229 if brands
230 .as_chunks::<4>()
231 .0
232 .iter()
233 .any(|b| matches!(b, b"mif1" | b"msf1"))
234 {
235 return Some("heif");
236 }
237 }
238 None
239}
240
241fn extension_format(extension: &str) -> &str {
242 match extension {
243 "jpg" | "jpe" => "jpeg",
244 "tif" => "tiff",
245 "heics" => "heic",
246 "" => "unknown",
247 other => other,
248 }
249}
250
251fn kind(format: &str) -> &'static str {
252 match format {
253 "png" | "jpeg" | "heic" | "heif" | "webp" | "gif" | "tiff" | "bmp" | "avif" | "jxl"
254 | "ico" | "icns" | "psd" => "image",
255 "svg" | "pdf" => "vector",
256 "mp4" | "mov" | "m4v" | "webm" | "avi" => "video",
257 "mp3" | "m4a" | "aac" | "wav" | "ogg" | "caf" | "flac" | "aiff" => "audio",
258 "svga" | "vap" | "tcmp4" | "lottie" => "animation",
259 "ttf" | "otf" | "woff" | "woff2" => "font",
260 "zip" | "gz" | "br" | "7z" | "rar" | "tar" => "archive",
261 "xcstrings" | "strings" | "stringsdict" => "localization",
262 "json" | "yaml" | "yml" | "toml" | "xml" | "plist" | "html" | "css" | "js" | "bin"
263 | "dat" | "txt" | "csv" | "db" | "sqlite" => "data",
264 _ => "unclassified",
265 }
266}
267
268fn is_source_or_tooling(name: &str, extension: &str) -> bool {
269 name.starts_with('.')
270 || matches!(
271 name,
272 "LICENSE" | "Makefile" | "Podfile" | "Gemfile" | "Rakefile"
273 )
274 || matches!(
275 extension,
276 "swift"
277 | "rs"
278 | "m"
279 | "mm"
280 | "h"
281 | "c"
282 | "cc"
283 | "cpp"
284 | "hpp"
285 | "kt"
286 | "java"
287 | "py"
288 | "pyc"
289 | "pyo"
290 | "sh"
291 | "rb"
292 | "toml"
293 | "lock"
294 | "md"
295 | "yml"
296 | "yaml"
297 | "pbxproj"
298 | "xcscheme"
299 | "xcworkspacedata"
300 | "xcuserstate"
301 | "xcconfig"
302 | "entitlements"
303 | "resolved"
304 )
305}
306
307pub(crate) fn bounded_read(path: &Path) -> Result<Vec<u8>> {
308 let mut bytes = Vec::new();
309 fs::File::open(path)?
310 .take(64 * 1024 * 1024 + 1)
311 .read_to_end(&mut bytes)?;
312 ensure!(bytes.len() <= 64 * 1024 * 1024, "input_exceeds_64_mib");
313 Ok(bytes)
314}