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        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
374/// Project kinds present under the scan root. A directory can hold several.
375fn 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}