Skip to main content

resopt/
resources.rs

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    /// Reason the file is excluded from every optimization, if any.
22    pub conversion_exclusion: Option<String>,
23    /// Reason the file must keep its encoded format; same-format lossless
24    /// optimization remains available.
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub format_lock: Option<String>,
27    /// Android resource semantics for files under `res/` or `assets/`.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub android: Option<crate::android::AndroidResource>,
30    /// `optimizable`, `excluded` or `unsupported` on this platform and build.
31    #[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    /// Detected project kinds: `xcode`, `swift_package`, `android`, or `directory`.
64    #[serde(default)]
65    pub project_kinds: Vec<String>,
66    /// Lowest declared Android `minSdk`, when it could be read from build files.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub android_min_sdk: Option<crate::android_project::MinSdk>,
69}
70
71/// Inventory all files other than recognized source/tooling files and build/VCS
72/// directories. Unknown files stay visible. This is not build-target resolution.
73pub 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            // Dependency source directories (Pods/Carthage/node_modules) remain
125            // visible in all-resource mode, unlike the legacy catalog-only plan.
126            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        // JSON is configuration, catalog metadata or localization, not a
180        // resource to optimize: listing it buried real assets under thousands
181        // of rows. The exception is a Lottie animation, which is artwork.
182        let lottie = extension == "json" && looks_like_lottie(&header[..read]);
183        if extension == "json" && !lottie {
184            report.skipped_source_or_tooling_files += 1;
185            continue;
186        }
187        let format = if lottie {
188            "lottie".to_string()
189        } else {
190            actual_format(&header[..read])
191                .unwrap_or_else(|| extension_format(&extension))
192                .to_string()
193        };
194        let kind = kind(&format).to_string();
195        let extension_mismatch =
196            matches!(kind.as_str(), "image") && extension_format(&extension) != format;
197        let referenced = references.get(&relative);
198        let origin = if referenced.is_some() {
199            "catalog_rendition"
200        } else if in_catalog {
201            "catalog_file"
202        } else {
203            "loose_file"
204        }
205        .to_string();
206        let android = crate::android::classify(&relative);
207        let conversion_exclusion = if relative.components().any(|p| {
208            Path::new(p.as_os_str())
209                .extension()
210                .is_some_and(|e| e == "appiconset")
211        }) {
212            Some("app_icon".into())
213        } else {
214            referenced
215                .and_then(|a| a.reason.as_ref())
216                .filter(|r| r.as_str() == "resizing")
217                .cloned()
218        };
219        let format_lock = android
220            .as_ref()
221            .and_then(|a| a.format_lock())
222            .map(str::to_string);
223        let support = if conversion_exclusion.is_some() {
224            "excluded"
225        } else if crate::capabilities::can_optimize(&kind, &format) {
226            "optimizable"
227        } else {
228            "unsupported"
229        }
230        .to_string();
231        report.assets.push(Resource {
232            path: relative,
233            bytes: entry.metadata()?.len(),
234            kind,
235            format,
236            extension,
237            extension_mismatch,
238            origin,
239            conversion_exclusion,
240            format_lock,
241            android,
242            support,
243        });
244    }
245    report.project_kinds = project_kinds(&filter, &report);
246    if report.project_kinds.iter().any(|kind| kind == "android") {
247        report.android_min_sdk = crate::android_project::detect_min_sdk(&report.root, &filter);
248    }
249    report.assets.sort_by(|a, b| a.path.cmp(&b.path));
250    report.excluded_directories.sort();
251    report.diagnostics.sort();
252    Ok(report)
253}
254
255/// Bodymovin/Lottie exports start with their version, frame rate and in/out
256/// points; ordinary JSON data does not carry that combination up front.
257fn looks_like_lottie(header: &[u8]) -> bool {
258    let text = String::from_utf8_lossy(header);
259    text.trim_start().starts_with('{')
260        && text.contains("\"v\"")
261        && text.contains("\"fr\"")
262        && (text.contains("\"ip\"") || text.contains("\"op\"") || text.contains("\"layers\""))
263}
264
265pub(crate) fn actual_format(bytes: &[u8]) -> Option<&'static str> {
266    if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
267        return Some("png");
268    }
269    if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
270        return Some("jpeg");
271    }
272    if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") {
273        return Some("webp");
274    }
275    if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
276        return Some("gif");
277    }
278    if bytes.starts_with(b"II*\0") || bytes.starts_with(b"MM\0*") {
279        return Some("tiff");
280    }
281    if bytes.starts_with(b"BM") {
282        return Some("bmp");
283    }
284    if bytes.starts_with(b"%PDF-") {
285        return Some("pdf");
286    }
287    if bytes.get(4..8) == Some(b"ftyp") && bytes.len() >= 16 {
288        let size = u32::from_be_bytes(bytes[..4].try_into().ok()?) as usize;
289        if size < 16 {
290            return None;
291        }
292        let brands = &bytes[8..size.min(bytes.len())];
293        if brands
294            .as_chunks::<4>()
295            .0
296            .iter()
297            .any(|b| matches!(b, b"avif" | b"avis"))
298        {
299            return Some("avif");
300        }
301        if brands
302            .as_chunks::<4>()
303            .0
304            .iter()
305            .any(|b| matches!(b, b"heic" | b"heix" | b"hevc" | b"hevx"))
306        {
307            return Some("heic");
308        }
309        if brands
310            .as_chunks::<4>()
311            .0
312            .iter()
313            .any(|b| matches!(b, b"mif1" | b"msf1"))
314        {
315            return Some("heif");
316        }
317    }
318    None
319}
320
321fn extension_format(extension: &str) -> &str {
322    match extension {
323        "jpg" | "jpe" => "jpeg",
324        "tif" => "tiff",
325        "heics" => "heic",
326        "" => "unknown",
327        other => other,
328    }
329}
330
331fn kind(format: &str) -> &'static str {
332    match format {
333        "png" | "jpeg" | "heic" | "heif" | "webp" | "gif" | "tiff" | "bmp" | "avif" | "jxl"
334        | "ico" | "icns" | "psd" => "image",
335        "svg" | "pdf" => "vector",
336        "mp4" | "mov" | "m4v" | "webm" | "avi" => "video",
337        "mp3" | "m4a" | "aac" | "wav" | "ogg" | "caf" | "flac" | "aiff" => "audio",
338        "svga" | "vap" | "tcmp4" | "lottie" | "pag" => "animation",
339        "ttf" | "otf" | "woff" | "woff2" => "font",
340        "zip" | "gz" | "br" | "7z" | "rar" | "tar" => "archive",
341        "xcstrings" | "strings" | "stringsdict" => "localization",
342        "json" | "yaml" | "yml" | "toml" | "xml" | "plist" | "html" | "css" | "js" | "bin"
343        | "dat" | "txt" | "csv" | "db" | "sqlite" => "data",
344        _ => "unclassified",
345    }
346}
347
348fn is_source_or_tooling(name: &str, extension: &str) -> bool {
349    name.starts_with('.')
350        || matches!(
351            name,
352            "LICENSE" | "Makefile" | "Podfile" | "Gemfile" | "Rakefile"
353        )
354        || matches!(
355            extension,
356            "swift"
357                | "rs"
358                | "m"
359                | "mm"
360                | "h"
361                | "c"
362                | "cc"
363                | "cpp"
364                | "hpp"
365                | "kt"
366                | "java"
367                | "py"
368                | "pyc"
369                | "pyo"
370                | "sh"
371                | "rb"
372                | "toml"
373                | "lock"
374                | "md"
375                | "yml"
376                | "yaml"
377                | "pbxproj"
378                | "xcscheme"
379                | "xcworkspacedata"
380                | "xcuserstate"
381                | "xcconfig"
382                | "entitlements"
383                | "resolved"
384        )
385}
386
387pub(crate) fn bounded_read(path: &Path) -> Result<Vec<u8>> {
388    let mut bytes = Vec::new();
389    fs::File::open(path)?
390        .take(64 * 1024 * 1024 + 1)
391        .read_to_end(&mut bytes)?;
392    ensure!(bytes.len() <= 64 * 1024 * 1024, "input_exceeds_64_mib");
393    Ok(bytes)
394}
395
396/// Project kinds present under the scan root. A directory can hold several.
397fn project_kinds(
398    filter: &crate::scan_options::ScanFilter,
399    report: &ResourceInventory,
400) -> Vec<String> {
401    let mut kinds = std::collections::BTreeSet::new();
402    for path in filter.paths() {
403        let name = path.file_name().unwrap_or_default().to_string_lossy();
404        if name.ends_with(".xcodeproj") || name.ends_with(".xcworkspace") {
405            kinds.insert("xcode");
406        } else if name == "Package.swift" {
407            kinds.insert("swift_package");
408        } else if name == "AndroidManifest.xml" {
409            kinds.insert("android");
410        }
411    }
412    if report.catalogs > 0 && !kinds.contains("swift_package") {
413        kinds.insert("xcode");
414    }
415    if report.assets.iter().any(|a| a.android.is_some()) {
416        kinds.insert("android");
417    }
418    if kinds.is_empty() {
419        kinds.insert("directory");
420    }
421    kinds.into_iter().map(str::to_string).collect()
422}