Skip to main content

romm_cli/core/
utils.rs

1use crate::types::{Rom, RomFile, RomFileCategory};
2
3/// One game entry for list display: same `name` on the same platform (base + updates/DLC) shown once.
4#[derive(Debug, Clone)]
5pub struct RomGroup {
6    pub name: String,
7    pub primary: Rom,
8    pub others: Vec<Rom>,
9}
10
11/// Group ROMs by game name and platform; primary is the "base" file (prefer over
12/// `"[Update]"` / `"[DLC]"` tags in `fs_name` when present).
13pub 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
50/// Human-readable file size.
51pub 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
98/// Group a Rom's files by category and return ordered `(label, total_bytes)` pairs.
99///
100/// Order: game, update, dlc, patch, hack, mod, translation, demo, prototype, cheat, manual, other.
101/// Returns an empty vector when `files` is empty. Categories with zero total bytes are omitted.
102pub 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
120/// Human-readable total size, optionally with a per-category breakdown from `Rom::files`.
121///
122/// When `files` is empty, returns [`format_size`] of `total` only. When there is exactly one
123/// non-empty bucket and it is `game`, returns the total without a parenthetical (single base file).
124pub 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
139/// Make a filename safe for the local filesystem.
140pub 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
152/// Truncate a string to `max` chars, appending "…" if trimmed.
153pub 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}