1use std::sync::OnceLock;
27
28pub fn friendly_camera_name(make: Option<&str>, model: Option<&str>) -> Option<String> {
31 let make_clean = make.and_then(clean_component);
32 let model_clean = model.and_then(clean_component);
33 let make = make_clean.as_deref();
34 let model = model_clean.as_deref();
35
36 if make.is_none() && model.is_none() {
37 return None;
38 }
39
40 if let Some(name) = pattern_transform(make, model) {
43 return Some(name);
44 }
45
46 if let Some(model) = model {
48 if let Some(name) = lookup_curated(model) {
49 return Some(name.to_string());
50 }
51 if let Some(name) = lookup_csv(model) {
53 return Some(name);
54 }
55 }
56
57 Some(combine_make_model(make, model))
59}
60
61fn clean_component(raw: &str) -> Option<String> {
62 let parts: Vec<&str> = if raw.contains("\",") || raw.contains(", \"") {
63 raw.split(',').collect()
64 } else {
65 vec![raw]
66 };
67 for part in parts {
68 let clean = part
69 .replace('\0', "")
70 .trim()
71 .trim_matches('"')
72 .trim()
73 .to_string();
74 if !clean.is_empty() {
75 return Some(clean);
76 }
77 }
78 None
79}
80
81fn pattern_transform(make: Option<&str>, model: Option<&str>) -> Option<String> {
82 let model = model?;
83
84 if let Some(name) = iphone_marketing_name(model) {
86 return Some(name.to_string());
87 }
88
89 if let Some(rest) = model.strip_prefix("ILCE-") {
91 if let Some(pretty) = sony_alpha_pretty(rest) {
92 return Some(format!("Sony α{}", pretty));
93 }
94 }
95 if let Some(rest) = model.strip_prefix("DSC-") {
97 if let Some(pretty) = sony_rx_pretty(rest) {
98 return Some(format!("Sony {}", pretty));
99 }
100 return Some(format!("Sony {}", rest));
101 }
102
103 if model.starts_with("EOS ") || model.starts_with("EOS-") || model.starts_with("Canon EOS") {
105 if model.starts_with("Canon ") {
106 return Some(model.to_string());
107 }
108 return Some(format!("Canon {}", model));
109 }
110
111 let make_lower = make.unwrap_or("").to_ascii_lowercase();
113 if make_lower.contains("nikon") {
114 if model.starts_with("Nikon ") {
115 return Some(model.to_string());
116 }
117 if model.starts_with('Z')
118 || model.starts_with('D')
119 || model.to_ascii_lowercase().starts_with("coolpix")
120 {
121 return Some(format!("Nikon {}", model));
122 }
123 }
124
125 if (make_lower.contains("fujifilm") || make_lower.contains("fuji"))
127 && (model.starts_with("X-")
128 || model.starts_with("GFX")
129 || model.starts_with("FinePix")
130 || model.starts_with("Fujifilm"))
131 {
132 return Some(format!(
133 "Fujifilm {}",
134 model.trim_start_matches("Fujifilm ")
135 ));
136 }
137
138 if model.starts_with("Pixel ") {
140 return Some(format!("Google {}", model));
141 }
142
143 None
144}
145
146fn sony_alpha_pretty(suffix: &str) -> Option<&'static str> {
149 Some(match suffix {
150 "7" => "7",
151 "7M2" => "7 II",
152 "7M3" => "7 III",
153 "7M4" => "7 IV",
154 "7M5" => "7 V",
155 "7R" => "7R",
156 "7RM2" => "7R II",
157 "7RM3" => "7R III",
158 "7RM4" => "7R IV",
159 "7RM5" => "7R V",
160 "7S" => "7S",
161 "7SM2" => "7S II",
162 "7SM3" => "7S III",
163 "7C" => "7C",
164 "7CM2" => "7C II",
165 "9" => "9",
166 "9M2" => "9 II",
167 "9M3" => "9 III",
168 "1" => "1",
169 "5100" => "5100",
170 "6000" => "6000",
171 "6100" => "6100",
172 "6300" => "6300",
173 "6400" => "6400",
174 "6500" => "6500",
175 "6600" => "6600",
176 "6700" => "6700",
177 _ => return None,
178 })
179}
180
181fn sony_rx_pretty(suffix: &str) -> Option<&'static str> {
182 Some(match suffix {
183 "RX100" => "RX100",
184 "RX100M2" => "RX100 II",
185 "RX100M3" => "RX100 III",
186 "RX100M4" => "RX100 IV",
187 "RX100M5" => "RX100 V",
188 "RX100M5A" => "RX100 VA",
189 "RX100M6" => "RX100 VI",
190 "RX100M7" => "RX100 VII",
191 "RX10" => "RX10",
192 "RX10M2" => "RX10 II",
193 "RX10M3" => "RX10 III",
194 "RX10M4" => "RX10 IV",
195 "RX1" => "RX1",
196 "RX1RM2" => "RX1R II",
197 _ => return None,
198 })
199}
200
201fn iphone_marketing_name(model: &str) -> Option<&'static str> {
203 Some(match model {
204 "iPhone1,1" => "iPhone",
206 "iPhone1,2" => "iPhone 3G",
207 "iPhone2,1" => "iPhone 3GS",
208 "iPhone3,1" | "iPhone3,2" | "iPhone3,3" => "iPhone 4",
209 "iPhone4,1" => "iPhone 4S",
210 "iPhone5,1" | "iPhone5,2" => "iPhone 5",
211 "iPhone5,3" | "iPhone5,4" => "iPhone 5c",
212 "iPhone6,1" | "iPhone6,2" => "iPhone 5s",
213 "iPhone7,1" => "iPhone 6 Plus",
214 "iPhone7,2" => "iPhone 6",
215 "iPhone8,1" => "iPhone 6s",
216 "iPhone8,2" => "iPhone 6s Plus",
217 "iPhone8,4" => "iPhone SE",
218 "iPhone9,1" | "iPhone9,3" => "iPhone 7",
219 "iPhone9,2" | "iPhone9,4" => "iPhone 7 Plus",
220 "iPhone10,1" | "iPhone10,4" => "iPhone 8",
221 "iPhone10,2" | "iPhone10,5" => "iPhone 8 Plus",
222 "iPhone10,3" | "iPhone10,6" => "iPhone X",
223 "iPhone11,2" => "iPhone XS",
224 "iPhone11,4" | "iPhone11,6" => "iPhone XS Max",
225 "iPhone11,8" => "iPhone XR",
226 "iPhone12,1" => "iPhone 11",
227 "iPhone12,3" => "iPhone 11 Pro",
228 "iPhone12,5" => "iPhone 11 Pro Max",
229 "iPhone12,8" => "iPhone SE (2nd gen)",
230 "iPhone13,1" => "iPhone 12 mini",
231 "iPhone13,2" => "iPhone 12",
232 "iPhone13,3" => "iPhone 12 Pro",
233 "iPhone13,4" => "iPhone 12 Pro Max",
234 "iPhone14,2" => "iPhone 13 Pro",
235 "iPhone14,3" => "iPhone 13 Pro Max",
236 "iPhone14,4" => "iPhone 13 mini",
237 "iPhone14,5" => "iPhone 13",
238 "iPhone14,6" => "iPhone SE (3rd gen)",
239 "iPhone14,7" => "iPhone 14",
240 "iPhone14,8" => "iPhone 14 Plus",
241 "iPhone15,2" => "iPhone 14 Pro",
242 "iPhone15,3" => "iPhone 14 Pro Max",
243 "iPhone15,4" => "iPhone 15",
244 "iPhone15,5" => "iPhone 15 Plus",
245 "iPhone16,1" => "iPhone 15 Pro",
246 "iPhone16,2" => "iPhone 15 Pro Max",
247 "iPhone17,1" => "iPhone 16 Pro",
248 "iPhone17,2" => "iPhone 16 Pro Max",
249 "iPhone17,3" => "iPhone 16",
250 "iPhone17,4" => "iPhone 16 Plus",
251 _ => return None,
252 })
253}
254
255fn lookup_curated(model: &str) -> Option<&'static str> {
259 let upper = model.to_ascii_uppercase();
261 Some(match upper.as_str() {
262 "M2007J20CI" | "M2007J20CG" | "M2007J20CT" => "Xiaomi Poco X3 NFC",
264 "M2007J17G" | "M2007J17I" => "Xiaomi Mi 10T Lite",
265 "M2007J3SY" | "M2007J3SI" => "Xiaomi Mi 10T Pro",
266 "M2007J3SG" => "Xiaomi Mi 10T",
267 "M2102J20SG" | "M2102J20SI" => "Xiaomi Poco X3 Pro",
268 "M2102J2SG" | "M2102J2SI" => "Xiaomi Mi 11i",
269 "M2102K1G" | "M2102K1AC" => "Xiaomi Mi 11",
270 "M2103K19PG" | "M2103K19PI" => "Xiaomi Redmi Note 10",
271 "M2101K6G" | "M2101K6P" | "M2101K6R" => "Xiaomi Redmi Note 10 Pro",
272 "M2010J19SG" | "M2010J19SI" | "M2010J19CI" => "Xiaomi Redmi 9T",
273 "21091116UG" | "21091116UI" => "Xiaomi Redmi Note 11 Pro",
274 "2201116TG" | "2201116TI" => "Xiaomi Redmi Note 11",
275 "2201117TG" | "2201117TI" => "Xiaomi Redmi Note 11S",
276 "2203121C" | "2203129G" => "Xiaomi 12",
277 "2201123G" | "2201122G" => "Xiaomi 12 Pro",
278 "2210132C" | "2210132G" => "Xiaomi 13",
279 "2211133G" | "2211133C" => "Xiaomi 13 Pro",
280 "23049PCD8G" | "23049PCD8I" => "Xiaomi 13 Lite",
281 "23116PN5BC" | "23116PN5BG" => "Xiaomi 14",
282 "23117RA68G" | "23127PN0CG" => "Xiaomi 14 Pro",
283
284 "GM1900" | "GM1903" | "GM1905" | "GM1911" | "GM1913" | "GM1915" | "GM1917" => "OnePlus 7",
286 "GM1910" | "GM1911A" | "GM1913A" | "GM1917A" => "OnePlus 7 Pro",
287 "HD1900" | "HD1901" | "HD1903" | "HD1905" | "HD1907" => "OnePlus 7T",
288 "HD1910" | "HD1911" | "HD1913" => "OnePlus 7T Pro",
289 "IN2010" | "IN2013" | "IN2015" | "IN2017" | "IN2019" => "OnePlus 8",
290 "IN2020" | "IN2023" | "IN2025" => "OnePlus 8 Pro",
291 "KB2000" | "KB2001" | "KB2003" | "KB2005" | "KB2007" => "OnePlus 8T",
292 "ONEPLUS A6010" | "ONEPLUS A6013" => "OnePlus 6T",
293 "LE2110" | "LE2111" | "LE2113" | "LE2115" | "LE2117" | "LE2119" | "LE2121" => "OnePlus 9",
294 "LE2120" | "LE2123" | "LE2125" | "LE2127" => "OnePlus 9 Pro",
295 "NE2210" | "NE2211" | "NE2213" | "NE2215" => "OnePlus 10 Pro",
296 "PHB110" | "PHB120" => "OnePlus 11",
297 "AC2001" | "AC2003" => "OnePlus Nord",
298 "DN2101" | "DN2103" => "OnePlus Nord 2",
299
300 "RMX2202" | "RMX2200" => "Realme 7 Pro",
302 "RMX3081" | "RMX3085" => "Realme 8 Pro",
303 "RMX3151" => "Realme 8",
304 "RMX3370" | "RMX3371" => "Realme GT",
305 "RMX3711" | "RMX3712" => "Realme GT Neo 3",
306
307 "CPH2173" | "CPH2179" => "Oppo Find X3 Pro",
309 "PEXM00" => "Oppo Find X5 Pro",
310
311 "SM-G991B" | "SM-G991U" | "SM-G991N" => "Samsung Galaxy S21",
313 "SM-G996B" | "SM-G996U" | "SM-G996N" => "Samsung Galaxy S21+",
314 "SM-G998B" | "SM-G998U" | "SM-G998N" => "Samsung Galaxy S21 Ultra",
315 "SM-S901B" | "SM-S901U" | "SM-S901N" => "Samsung Galaxy S22",
316 "SM-S906B" | "SM-S906U" | "SM-S906N" => "Samsung Galaxy S22+",
317 "SM-S908B" | "SM-S908U" | "SM-S908N" => "Samsung Galaxy S22 Ultra",
318 "SM-S911B" | "SM-S911U" | "SM-S911N" => "Samsung Galaxy S23",
319 "SM-S916B" | "SM-S916U" | "SM-S916N" => "Samsung Galaxy S23+",
320 "SM-S918B" | "SM-S918U" | "SM-S918N" => "Samsung Galaxy S23 Ultra",
321 "SM-S921B" | "SM-S921U" | "SM-S921N" => "Samsung Galaxy S24",
322 "SM-S926B" | "SM-S926U" | "SM-S926N" => "Samsung Galaxy S24+",
323 "SM-S928B" | "SM-S928U" | "SM-S928N" => "Samsung Galaxy S24 Ultra",
324
325 "ASUS_AI2202" => "Asus ROG Phone 6",
327 _ => return None,
328 })
329}
330
331fn lookup_csv(model: &str) -> Option<String> {
336 static MAP: OnceLock<Option<std::collections::HashMap<String, String>>> = OnceLock::new();
337 let map = MAP.get_or_init(load_csv);
338 map.as_ref()?.get(&model.to_ascii_uppercase()).cloned()
339}
340
341fn load_csv() -> Option<std::collections::HashMap<String, String>> {
342 let candidates = [
343 std::path::PathBuf::from("data/device_codenames.csv"),
344 std::env::current_exe()
345 .ok()
346 .and_then(|p| p.parent().map(|d| d.join("data/device_codenames.csv")))
347 .unwrap_or_default(),
348 ];
349 for path in candidates.iter() {
350 if !path.is_file() {
351 continue;
352 }
353 let Ok(content) = std::fs::read_to_string(path) else {
354 continue;
355 };
356 let mut map = std::collections::HashMap::new();
357 for (i, line) in content.lines().enumerate() {
358 if i == 0 && line.to_ascii_lowercase().contains("retail_branding") {
359 continue; }
361 let cells: Vec<&str> = line.split(',').collect();
362 if cells.len() < 4 {
363 continue;
364 }
365 let brand = cells[0].trim();
366 let marketing = cells[1].trim();
367 let model_code = cells[3].trim();
368 if model_code.is_empty() || marketing.is_empty() {
369 continue;
370 }
371 let display = if brand.is_empty()
372 || marketing
373 .to_ascii_lowercase()
374 .starts_with(&brand.to_ascii_lowercase())
375 {
376 marketing.to_string()
377 } else {
378 format!("{} {}", brand, marketing)
379 };
380 map.insert(model_code.to_ascii_uppercase(), display);
381 }
382 return Some(map);
383 }
384 Some(std::collections::HashMap::new())
385}
386
387fn combine_make_model(make: Option<&str>, model: Option<&str>) -> String {
389 match (make, model) {
390 (Some(make), Some(model)) => {
391 let model_lower = model.to_ascii_lowercase();
394 let make_lower = make.to_ascii_lowercase();
395 if model_lower.starts_with(&make_lower) {
396 pretty_case(model)
397 } else {
398 format!("{} {}", make, pretty_case(model))
399 }
400 }
401 (None, Some(model)) => pretty_case(model),
402 (Some(make), None) => make.to_string(),
403 (None, None) => String::new(),
404 }
405}
406
407fn pretty_case(model: &str) -> String {
410 let has_space = model.contains(' ');
411 let all_upper = model
412 .chars()
413 .all(|c| !c.is_alphabetic() || c.is_uppercase());
414 let all_lower = model
415 .chars()
416 .all(|c| !c.is_alphabetic() || c.is_lowercase());
417
418 if has_space && !all_upper && !all_lower {
420 return model.to_string();
421 }
422
423 if !has_space {
427 return model.to_uppercase();
428 }
429
430 model
432 .split_whitespace()
433 .map(|w| {
434 let mut chars = w.chars();
435 match chars.next() {
436 Some(c) => c.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase(),
437 None => String::new(),
438 }
439 })
440 .collect::<Vec<_>>()
441 .join(" ")
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447
448 #[test]
449 fn xiaomi_codename() {
450 assert_eq!(
451 friendly_camera_name(Some("Xiaomi"), Some("M2007J20CI")).unwrap(),
452 "Xiaomi Poco X3 NFC"
453 );
454 assert_eq!(
456 friendly_camera_name(Some("Xiaomi"), Some("m2007j20ci")).unwrap(),
457 "Xiaomi Poco X3 NFC"
458 );
459 }
460
461 #[test]
462 fn sony_alpha() {
463 assert_eq!(
464 friendly_camera_name(Some("SONY"), Some("ILCE-7M3")).unwrap(),
465 "Sony α7 III"
466 );
467 assert_eq!(
468 friendly_camera_name(Some("SONY"), Some("ILCE-7RM4")).unwrap(),
469 "Sony α7R IV"
470 );
471 assert_eq!(
472 friendly_camera_name(Some("SONY"), Some("ILCE-6400")).unwrap(),
473 "Sony α6400"
474 );
475 }
476
477 #[test]
478 fn iphone_devicecode() {
479 assert_eq!(
480 friendly_camera_name(Some("Apple"), Some("iPhone15,2")).unwrap(),
481 "iPhone 14 Pro"
482 );
483 assert_eq!(
484 friendly_camera_name(Some("Apple"), Some("iPhone16,2")).unwrap(),
485 "iPhone 15 Pro Max"
486 );
487 }
488
489 #[test]
490 fn canon_eos_passes_through() {
491 assert_eq!(
492 friendly_camera_name(Some("Canon"), Some("EOS R5")).unwrap(),
493 "Canon EOS R5"
494 );
495 }
496
497 #[test]
498 fn fallback_dedup() {
499 assert_eq!(
501 friendly_camera_name(Some("Canon"), Some("Canon PowerShot S110")).unwrap(),
502 "Canon PowerShot S110"
503 );
504 }
505
506 #[test]
507 fn unknown_codename_uppercase() {
508 assert_eq!(
511 friendly_camera_name(Some("UnknownMaker"), Some("xyz123abc")).unwrap(),
512 "UnknownMaker XYZ123ABC"
513 );
514 }
515
516 #[test]
517 fn comma_quoted_empty_exif_noise_is_ignored() {
518 assert_eq!(
519 friendly_camera_name(
520 Some(r#""OnePlus", "", "", "", """#),
521 Some(r#""ONEPLUS A6010", "", """#)
522 )
523 .unwrap(),
524 "OnePlus 6T"
525 );
526 }
527
528 #[test]
529 fn empty_inputs() {
530 assert!(friendly_camera_name(None, None).is_none());
531 assert!(friendly_camera_name(Some(""), Some("")).is_none());
532 }
533}