1use crate::filesystem::{contained_file, hash};
2use anyhow::{Context, Result, ensure};
3use serde::{Deserialize, Serialize};
4use std::{
5 collections::BTreeMap,
6 fs,
7 path::{Path, PathBuf},
8};
9use walkdir::WalkDir;
10use xcassets::{RenditionSet, RenditionSetKind};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Asset {
14 pub path: PathBuf,
16 pub bytes: u64,
17 pub eligible: bool,
18 pub reason: Option<String>,
19 pub contents_path: PathBuf,
20 pub contents_sha256: String,
21}
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct Inventory {
25 pub schema_version: u32,
26 pub root: PathBuf,
27 pub catalogs: usize,
28 pub assets: Vec<Asset>,
29 pub diagnostics: Vec<String>,
30}
31
32pub fn scan(root: impl AsRef<Path>) -> Result<Inventory> {
35 scan_with_options(root, crate::ScanOptions::default())
36}
37
38pub fn scan_with_options(root: impl AsRef<Path>, options: crate::ScanOptions) -> Result<Inventory> {
39 let root = fs::canonicalize(root.as_ref()).context("resolving project root")?;
40 let filter = crate::scan_options::ScanFilter::new(&root, options)?;
41 scan_filtered(&root, &filter)
42}
43
44pub(crate) fn scan_filtered(
45 root: &Path,
46 filter: &crate::scan_options::ScanFilter,
47) -> Result<Inventory> {
48 let root = root.to_path_buf();
49 ensure!(root.is_dir(), "scan root must be a directory");
50 let mut inventory = Inventory {
51 schema_version: 1,
52 root: root.clone(),
53 catalogs: 0,
54 assets: vec![],
55 diagnostics: filter.diagnostics.clone(),
56 };
57 let mut walk = WalkDir::new(&root).follow_links(false).into_iter();
58 while let Some(entry) = walk.next() {
59 let entry = match entry {
60 Ok(entry) => entry,
61 Err(error) => {
62 inventory.diagnostics.push(error.to_string());
63 continue;
64 }
65 };
66 if !entry.file_type().is_dir() {
67 continue;
68 }
69 if !filter.allows(entry.path()) {
71 walk.skip_current_dir();
72 continue;
73 }
74 if entry.depth() > 0 && excluded(entry.file_name().to_str().unwrap_or("")) {
75 walk.skip_current_dir();
76 continue;
77 }
78 if entry
79 .path()
80 .extension()
81 .is_some_and(|ext| ext == "xcassets")
82 {
83 walk.skip_current_dir();
84 inventory.catalogs += 1;
85 let unsafe_tree = WalkDir::new(entry.path())
88 .follow_links(false)
89 .into_iter()
90 .any(|child| child.map_or(true, |child| child.file_type().is_symlink()));
91 if unsafe_tree {
92 inventory.diagnostics.push(format!(
93 "skipped catalog with symlinks or unreadable entries: {}",
94 entry.path().display()
95 ));
96 continue;
97 }
98 match xcassets::parse_catalog(entry.path()) {
99 Ok(report) => {
100 for diagnostic in report.diagnostics {
101 inventory.diagnostics.push(format!(
102 "{}: {}",
103 diagnostic.path.display(),
104 diagnostic.message
105 ));
106 }
107 let index = xcassets::index_renditions(&report.catalog);
108 for path in index.unsupported_nodes {
109 inventory.diagnostics.push(format!(
110 "unsupported catalog node: {}",
111 entry.path().join(path).display()
112 ));
113 }
114 for set in index.sets {
115 visit_set(&set, entry.path(), &mut inventory)?;
116 }
117 }
118 Err(error) => inventory.diagnostics.push(error.to_string()),
119 }
120 }
121 }
122 let mut unique: BTreeMap<PathBuf, Asset> = BTreeMap::new();
124 for asset in inventory.assets.drain(..) {
125 if !filter.allows(&root.join(&asset.path))
126 || !filter.allows(&root.join(&asset.contents_path))
127 {
128 continue;
129 }
130 match unique.get(&asset.path) {
131 Some(previous) if !previous.eligible => {}
132 _ => {
133 unique.insert(asset.path.clone(), asset);
134 }
135 }
136 }
137 inventory.assets = unique.into_values().collect();
138 inventory.diagnostics.sort();
139 Ok(inventory)
140}
141
142pub(crate) fn excluded(name: &str) -> bool {
143 matches!(
144 name,
145 ".git"
146 | ".worktrees"
147 | ".worktree"
148 | ".build"
149 | ".swiftpm"
150 | ".resopt"
151 | "target"
152 | "build"
153 | "DerivedData"
154 | "Pods"
155 | "Carthage"
156 | "node_modules"
157 )
158}
159
160fn visit_set(set: &RenditionSet<'_>, catalog: &Path, inventory: &mut Inventory) -> Result<()> {
161 let directory = catalog.join(set.relative_path);
162 let contents_path = directory.join("Contents.json");
163 let relative_contents = contents_path.strip_prefix(&inventory.root)?.to_path_buf();
164 let contents_bytes = fs::read(&contents_path)?;
165 ensure!(
166 set.matches_contents(&contents_bytes)?,
167 "catalog changed while scanning: {}",
168 contents_path.display()
169 );
170 let contents_hash = hash(&contents_bytes);
171 let special = if set.kind == RenditionSetKind::AppIcon {
172 Some("app_icon")
173 } else if set.has_resizing {
174 Some("resizing")
175 } else {
176 None
177 };
178 for image in set.images {
179 let Some(filename) = image.filename.as_deref() else {
180 continue;
181 };
182 let filename_path = Path::new(filename);
184 if !xcassets::is_rendition_filename(filename) {
185 inventory.diagnostics.push(format!(
186 "unsafe rendition filename in {}: {filename}",
187 contents_path.display()
188 ));
189 continue;
190 }
191 let path = directory
192 .join(filename)
193 .strip_prefix(&inventory.root)?
194 .to_path_buf();
195 let source = match contained_file(&inventory.root, &path) {
196 Ok(source) => source,
197 Err(error) => {
198 inventory.diagnostics.push(error.to_string());
199 continue;
200 }
201 };
202 let reason = special.or_else(|| {
203 if filename_path
204 .extension()
205 .is_some_and(|ext| ext.eq_ignore_ascii_case("png"))
206 {
207 None
208 } else {
209 Some("unsupported_format")
210 }
211 });
212 inventory.assets.push(Asset {
213 path,
214 bytes: fs::metadata(source)?.len(),
215 eligible: reason.is_none(),
216 reason: reason.map(str::to_string),
217 contents_path: relative_contents.clone(),
218 contents_sha256: contents_hash.clone(),
219 });
220 }
221 Ok(())
222}