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