Skip to main content

romm_cli/core/
utils.rs

1use crate::types::Rom;
2
3/// One game entry for list display: same `name` (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; 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_name: HashMap<String, Vec<Rom>> = HashMap::new();
16    for rom in items {
17        by_name
18            .entry(rom.name.clone())
19            .or_default()
20            .push(rom.clone());
21    }
22    let mut groups = Vec::with_capacity(by_name.len());
23    for (name, mut roms) in by_name {
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| a.name.cmp(&b.name));
43    groups
44}
45
46/// Human-readable file size.
47pub fn format_size(bytes: u64) -> String {
48    const KB: u64 = 1024;
49    const MB: u64 = KB * 1024;
50    const GB: u64 = MB * 1024;
51    if bytes >= GB {
52        format!("{:.2} GB", bytes as f64 / GB as f64)
53    } else if bytes >= MB {
54        format!("{:.2} MB", bytes as f64 / MB as f64)
55    } else if bytes >= KB {
56        format!("{:.2} KB", bytes as f64 / KB as f64)
57    } else {
58        format!("{} B", bytes)
59    }
60}
61
62/// Make a filename safe for the local filesystem.
63pub fn sanitize_filename(name: &str) -> String {
64    name.chars()
65        .map(|c| {
66            if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == ' ' {
67                c
68            } else {
69                '_'
70            }
71        })
72        .collect()
73}
74
75/// Truncate a string to `max` chars, appending "…" if trimmed.
76pub fn truncate(s: &str, max: usize) -> String {
77    let s = s.trim();
78    if s.chars().count() <= max {
79        s.to_string()
80    } else {
81        format!(
82            "{}…",
83            s.chars().take(max.saturating_sub(1)).collect::<String>()
84        )
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::types::Rom;
92
93    fn rom(id: u64, name: &str, fs_name: &str) -> Rom {
94        Rom {
95            id,
96            platform_id: 1,
97            platform_slug: None,
98            platform_fs_slug: None,
99            platform_custom_name: Some("NES".to_string()),
100            platform_display_name: Some("NES".to_string()),
101            fs_name: fs_name.to_string(),
102            fs_name_no_tags: name.to_string(),
103            fs_name_no_ext: name.to_string(),
104            fs_extension: "zip".to_string(),
105            fs_path: format!("/roms/{}.zip", id),
106            fs_size_bytes: 1,
107            name: name.to_string(),
108            slug: None,
109            summary: None,
110            path_cover_small: None,
111            path_cover_large: None,
112            url_cover: None,
113            is_unidentified: false,
114            is_identified: true,
115        }
116    }
117
118    #[test]
119    fn group_roms_prefers_base_file_as_primary() {
120        let input = vec![
121            rom(1, "Game A", "Game A [Update].zip"),
122            rom(2, "Game A", "Game A [DLC].zip"),
123            rom(3, "Game A", "Game A.zip"),
124            rom(4, "Game B", "Game B.zip"),
125        ];
126
127        let groups = group_roms_by_name(&input);
128        assert_eq!(groups.len(), 2);
129
130        let game_a = groups.iter().find(|g| g.name == "Game A").expect("group");
131        assert_eq!(game_a.primary.fs_name, "Game A.zip");
132        assert_eq!(game_a.others.len(), 2);
133    }
134}