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    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
35/// Inventory all files other than recognized source/tooling files and build/VCS
36/// directories. Unknown files stay visible. This is not build-target resolution.
37pub 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            // Dependency source directories (Pods/Carthage/node_modules) remain
71            // visible in all-resource mode, unlike the legacy catalog-only plan.
72            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}