1use crate::parsers::zip_extractor::extract_ipa;
2use serde::Serialize;
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, PartialEq)]
8pub struct SizeReport {
9 pub app_path: String,
10 pub total_bytes: u64,
11 pub categories: Vec<CategorySummary>,
12 pub top_files: Vec<SizeEntry>,
13}
14
15#[derive(Debug, Clone, Serialize, PartialEq)]
16pub struct CategorySummary {
17 pub category: String,
18 pub bytes: u64,
19 pub file_count: usize,
20 pub percent_of_total: f64,
21}
22
23#[derive(Debug, Clone, Serialize, PartialEq)]
24pub struct SizeEntry {
25 pub path: String,
26 pub category: String,
27 pub bytes: u64,
28}
29
30pub fn analyze_app_size(path: &Path, top_n: usize) -> Result<SizeReport, miette::Report> {
31 let extension = path.extension().and_then(|ext| ext.to_str());
32 match extension {
33 Some("ipa") => {
34 let extracted = extract_ipa(path).map_err(|err| {
35 miette::miette!("Failed to extract IPA {}: {}", path.display(), err)
36 })?;
37 let app_bundle = extracted
38 .get_app_bundle_path()
39 .map_err(|err| {
40 miette::miette!(
41 "Failed to inspect extracted IPA payload for {}: {}",
42 path.display(),
43 err
44 )
45 })?
46 .ok_or_else(|| {
47 miette::miette!(
48 "No .app bundle found inside extracted IPA {}",
49 path.display()
50 )
51 })?;
52 analyze_app_bundle(&app_bundle, top_n)
53 }
54 Some("app") | None => analyze_app_bundle(path, top_n),
55 Some(other) => Err(miette::miette!(
56 "Unsupported app artifact `{}`. Expected .ipa or .app",
57 other
58 )),
59 }
60}
61
62pub fn analyze_app_bundle(
63 app_bundle_path: &Path,
64 top_n: usize,
65) -> Result<SizeReport, miette::Report> {
66 if !app_bundle_path.exists() {
67 return Err(miette::miette!(
68 "App bundle does not exist: {}",
69 app_bundle_path.display()
70 ));
71 }
72
73 let mut entries = Vec::new();
74 collect_files(app_bundle_path, app_bundle_path, &mut entries)?;
75 entries.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.path.cmp(&b.path)));
76
77 let total_bytes = entries.iter().map(|item| item.bytes).sum::<u64>();
78 let categories = summarize_categories(&entries, total_bytes);
79 let top_files = entries.into_iter().take(top_n).collect();
80
81 Ok(SizeReport {
82 app_path: app_bundle_path.display().to_string(),
83 total_bytes,
84 categories,
85 top_files,
86 })
87}
88
89fn collect_files(
90 root: &Path,
91 current: &Path,
92 entries: &mut Vec<SizeEntry>,
93) -> Result<(), miette::Report> {
94 for entry in fs::read_dir(current)
95 .map_err(|err| miette::miette!("Failed to read {}: {}", current.display(), err))?
96 {
97 let entry = entry
98 .map_err(|err| miette::miette!("Failed to walk {}: {}", current.display(), err))?;
99 let path = entry.path();
100 if path.is_dir() {
101 collect_files(root, &path, entries)?;
102 continue;
103 }
104
105 let metadata = entry
106 .metadata()
107 .map_err(|err| miette::miette!("Failed to stat {}: {}", path.display(), err))?;
108 let relative = normalized_relative_path(root, &path);
109 entries.push(SizeEntry {
110 path: relative,
111 category: classify_path(root, &path).to_string(),
112 bytes: metadata.len(),
113 });
114 }
115
116 Ok(())
117}
118
119fn summarize_categories(entries: &[SizeEntry], total_bytes: u64) -> Vec<CategorySummary> {
120 let mut by_category: BTreeMap<String, (u64, usize)> = BTreeMap::new();
121 for entry in entries {
122 let row = by_category.entry(entry.category.clone()).or_insert((0, 0));
123 row.0 += entry.bytes;
124 row.1 += 1;
125 }
126
127 let mut categories: Vec<CategorySummary> = by_category
128 .into_iter()
129 .map(|(category, (bytes, file_count))| CategorySummary {
130 category,
131 bytes,
132 file_count,
133 percent_of_total: if total_bytes == 0 {
134 0.0
135 } else {
136 ((bytes as f64 / total_bytes as f64) * 1000.0).round() / 10.0
137 },
138 })
139 .collect();
140 categories.sort_by(|a, b| {
141 b.bytes
142 .cmp(&a.bytes)
143 .then_with(|| a.category.cmp(&b.category))
144 });
145 categories
146}
147
148fn classify_path(root: &Path, path: &Path) -> &'static str {
149 let relative = path.strip_prefix(root).unwrap_or(path);
150 let parts: Vec<String> = relative
151 .components()
152 .map(|part| part.as_os_str().to_string_lossy().to_string())
153 .collect();
154
155 if parts.iter().any(|part| part == "Frameworks") {
156 return "framework";
157 }
158 if parts
159 .iter()
160 .any(|part| part == "PlugIns" || part == "Extensions")
161 {
162 return "extension";
163 }
164
165 let file_name = path
166 .file_name()
167 .and_then(|name| name.to_str())
168 .unwrap_or("");
169 let extension = path
170 .extension()
171 .and_then(|ext| ext.to_str())
172 .unwrap_or("")
173 .to_ascii_lowercase();
174
175 if matches!(
176 file_name,
177 "Info.plist" | "PkgInfo" | "embedded.mobileprovision" | "PrivacyInfo.xcprivacy"
178 ) || matches!(extension.as_str(), "plist" | "strings" | "mobileprovision")
179 {
180 return "metadata";
181 }
182
183 if matches!(
184 extension.as_str(),
185 "png"
186 | "jpg"
187 | "jpeg"
188 | "gif"
189 | "webp"
190 | "heic"
191 | "car"
192 | "pdf"
193 | "json"
194 | "ttf"
195 | "otf"
196 | "mp3"
197 | "mp4"
198 | "wav"
199 | "storyboardc"
200 | "nib"
201 ) || file_name == "Assets.car"
202 {
203 return "asset";
204 }
205
206 if extension.is_empty() || matches!(extension.as_str(), "dylib" | "metallib") {
207 return "binary";
208 }
209
210 "resource"
211}
212
213fn normalized_relative_path(root: &Path, path: &Path) -> String {
214 path.strip_prefix(root)
215 .unwrap_or(path)
216 .components()
217 .map(|part| part.as_os_str().to_string_lossy().to_string())
218 .collect::<Vec<String>>()
219 .join("/")
220}
221
222#[cfg(test)]
223mod tests {
224 use super::analyze_app_bundle;
225 use std::fs;
226 use tempfile::tempdir;
227
228 #[test]
229 fn analyzes_bundle_size_breakdown() {
230 let dir = tempdir().expect("temp dir");
231 let app = dir.path().join("Demo.app");
232 fs::create_dir_all(app.join("Frameworks/Foo.framework")).expect("framework dir");
233 fs::create_dir_all(app.join("PlugIns/Share.appex")).expect("appex dir");
234 fs::write(app.join("Demo"), vec![0u8; 10]).expect("write binary");
235 fs::write(app.join("Assets.car"), vec![0u8; 20]).expect("write asset");
236 fs::write(app.join("Frameworks/Foo.framework/Foo"), vec![0u8; 30])
237 .expect("write framework");
238 fs::write(app.join("PlugIns/Share.appex/Share"), vec![0u8; 15]).expect("write appex");
239 fs::write(app.join("Info.plist"), vec![0u8; 5]).expect("write plist");
240
241 let report = analyze_app_bundle(&app, 3).expect("analyze bundle");
242
243 assert_eq!(report.total_bytes, 80);
244 assert_eq!(report.top_files.len(), 3);
245 assert_eq!(report.top_files[0].path, "Frameworks/Foo.framework/Foo");
246 assert_eq!(report.top_files[0].category, "framework");
247 assert_eq!(report.categories[0].category, "framework");
248 assert_eq!(report.categories[0].bytes, 30);
249 assert!(report
250 .categories
251 .iter()
252 .any(|item| item.category == "asset"));
253 assert!(report
254 .categories
255 .iter()
256 .any(|item| item.category == "binary"));
257 assert!(report
258 .categories
259 .iter()
260 .any(|item| item.category == "extension"));
261 assert!(report
262 .categories
263 .iter()
264 .any(|item| item.category == "metadata"));
265 }
266}