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    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            // Dependency source directories (Pods/Carthage/node_modules) remain
87            // visible in all-resource mode, unlike the legacy catalog-only plan.
88            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}