1use crate::types::{Rom, RomFile, RomFileCategory};
2
3#[derive(Debug, Clone)]
5pub struct RomGroup {
6 pub name: String,
7 pub primary: Rom,
8 pub others: Vec<Rom>,
9}
10
11pub fn group_roms_by_name(items: &[Rom]) -> Vec<RomGroup> {
14 use std::collections::HashMap;
15 let mut by_group: HashMap<(String, u64), Vec<Rom>> = HashMap::new();
16 for rom in items {
17 by_group
18 .entry((rom.name.clone(), rom.platform_id))
19 .or_default()
20 .push(rom.clone());
21 }
22 let mut groups = Vec::with_capacity(by_group.len());
23 for ((name, _platform_id), mut roms) in by_group {
24 roms.sort_by(|a, b| {
25 let a_extra = a.fs_name.to_lowercase().contains("[update]")
26 || a.fs_name.to_lowercase().contains("[dlc]");
27 let b_extra = b.fs_name.to_lowercase().contains("[update]")
28 || b.fs_name.to_lowercase().contains("[dlc]");
29 match (a_extra, b_extra) {
30 (false, true) => std::cmp::Ordering::Less,
31 (true, false) => std::cmp::Ordering::Greater,
32 _ => std::cmp::Ordering::Equal,
33 }
34 });
35 let primary = roms.remove(0);
36 groups.push(RomGroup {
37 name,
38 primary,
39 others: roms,
40 });
41 }
42 groups.sort_by(|a, b| {
43 a.name
44 .cmp(&b.name)
45 .then_with(|| a.primary.platform_id.cmp(&b.primary.platform_id))
46 });
47 groups
48}
49
50pub fn format_size(bytes: u64) -> String {
52 const KB: u64 = 1024;
53 const MB: u64 = KB * 1024;
54 const GB: u64 = MB * 1024;
55 if bytes >= GB {
56 format!("{:.2} GB", bytes as f64 / GB as f64)
57 } else if bytes >= MB {
58 format!("{:.2} MB", bytes as f64 / MB as f64)
59 } else if bytes >= KB {
60 format!("{:.2} KB", bytes as f64 / KB as f64)
61 } else {
62 format!("{} B", bytes)
63 }
64}
65
66fn category_bucket_index(cat: Option<&RomFileCategory>) -> usize {
67 match cat {
68 Some(RomFileCategory::Game) => 0,
69 Some(RomFileCategory::Update) => 1,
70 Some(RomFileCategory::Dlc) => 2,
71 Some(RomFileCategory::Patch) => 3,
72 Some(RomFileCategory::Hack) => 4,
73 Some(RomFileCategory::Mod) => 5,
74 Some(RomFileCategory::Translation) => 6,
75 Some(RomFileCategory::Demo) => 7,
76 Some(RomFileCategory::Prototype) => 8,
77 Some(RomFileCategory::Cheat) => 9,
78 Some(RomFileCategory::Manual) => 10,
79 None => 11,
80 }
81}
82
83const CATEGORY_BUCKET_LABELS: [&str; 12] = [
84 "game",
85 "update",
86 "dlc",
87 "patch",
88 "hack",
89 "mod",
90 "translation",
91 "demo",
92 "prototype",
93 "cheat",
94 "manual",
95 "other",
96];
97
98pub fn size_breakdown_by_category(files: &[RomFile]) -> Vec<(&'static str, u64)> {
103 if files.is_empty() {
104 return Vec::new();
105 }
106 let mut sums = [0u64; 12];
107 for f in files {
108 let i = category_bucket_index(f.category.as_ref());
109 sums[i] = sums[i].saturating_add(f.file_size_bytes);
110 }
111 let mut out = Vec::new();
112 for (i, label) in CATEGORY_BUCKET_LABELS.iter().enumerate() {
113 if sums[i] > 0 {
114 out.push((*label, sums[i]));
115 }
116 }
117 out
118}
119
120pub fn format_size_with_breakdown(total: u64, files: &[RomFile]) -> String {
125 let breakdown = size_breakdown_by_category(files);
126 if breakdown.is_empty() {
127 return format_size(total);
128 }
129 if breakdown.len() == 1 && breakdown[0].0 == "game" {
130 return format_size(total);
131 }
132 let parts: Vec<String> = breakdown
133 .iter()
134 .map(|(label, bytes)| format!("{} {}", label, format_size(*bytes)))
135 .collect();
136 format!("{} ({})", format_size(total), parts.join(" + "))
137}
138
139pub fn sanitize_filename(name: &str) -> String {
141 name.chars()
142 .map(|c| {
143 if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == ' ' {
144 c
145 } else {
146 '_'
147 }
148 })
149 .collect()
150}
151
152pub fn truncate(s: &str, max: usize) -> String {
154 let s = s.trim();
155 if s.chars().count() <= max {
156 s.to_string()
157 } else {
158 format!(
159 "{}…",
160 s.chars().take(max.saturating_sub(1)).collect::<String>()
161 )
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168 use crate::types::Rom;
169
170 fn rom(id: u64, platform_id: u64, name: &str, fs_name: &str) -> Rom {
171 Rom {
172 id,
173 platform_id,
174 platform_slug: None,
175 platform_fs_slug: None,
176 platform_custom_name: Some("NES".to_string()),
177 platform_display_name: Some("NES".to_string()),
178 fs_name: fs_name.to_string(),
179 fs_name_no_tags: name.to_string(),
180 fs_name_no_ext: name.to_string(),
181 fs_extension: "zip".to_string(),
182 fs_path: format!("/roms/{}.zip", id),
183 fs_size_bytes: 1,
184 name: name.to_string(),
185 slug: None,
186 summary: None,
187 path_cover_small: None,
188 path_cover_large: None,
189 url_cover: None,
190 has_manual: false,
191 path_manual: None,
192 url_manual: None,
193 is_unidentified: false,
194 is_identified: true,
195 files: Vec::new(),
196 }
197 }
198
199 #[test]
200 fn group_roms_prefers_base_file_as_primary() {
201 let input = vec![
202 rom(1, 1, "Game A", "Game A [Update].zip"),
203 rom(2, 1, "Game A", "Game A [DLC].zip"),
204 rom(3, 1, "Game A", "Game A.zip"),
205 rom(4, 1, "Game B", "Game B.zip"),
206 ];
207
208 let groups = group_roms_by_name(&input);
209 assert_eq!(groups.len(), 2);
210
211 let game_a = groups.iter().find(|g| g.name == "Game A").expect("group");
212 assert_eq!(game_a.primary.fs_name, "Game A.zip");
213 assert_eq!(game_a.others.len(), 2);
214 }
215
216 #[test]
217 fn group_roms_separates_same_title_by_platform() {
218 let input = vec![
219 rom(
220 1,
221 1,
222 "Paper Mario: The Thousand-Year Door",
223 "Paper Mario.zip",
224 ),
225 rom(
226 2,
227 2,
228 "Paper Mario: The Thousand-Year Door",
229 "Paper Mario (Switch).zip",
230 ),
231 ];
232
233 let groups = group_roms_by_name(&input);
234 assert_eq!(groups.len(), 2);
235 assert_eq!(groups[0].primary.platform_id, 1);
236 assert_eq!(groups[1].primary.platform_id, 2);
237 }
238}