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