Skip to main content

leenfetch_core/modules/linux/info/
disk.rs

1use std::fs;
2
3use crate::modules::{
4    enums::{DiskDisplayPart, DiskSubtitle, parse_disk_display_parts},
5    utils::get_bar,
6};
7
8pub fn get_disks(
9    subtitle_mode: DiskSubtitle,
10    display_mode: &str,
11    paths: Option<Vec<&str>>,
12) -> Option<Vec<(String, String)>> {
13    // Get mount points - read /proc/mounts directly instead of spawning df
14    let mount_points = if let Some(ref user_paths) = paths {
15        user_paths.iter().map(|s| s.to_string()).collect()
16    } else {
17        // Default: pick the most useful visible mount per filesystem
18        get_default_mount_points()
19    };
20
21    let mut results = Vec::new();
22
23    for mount_point in mount_points {
24        if let Some(disk_info) = get_disk_info_for_path(&mount_point, subtitle_mode, &display_mode)
25        {
26            results.push(disk_info);
27        }
28    }
29
30    if results.is_empty() {
31        return None;
32    }
33
34    Some(results)
35}
36
37fn get_default_mount_points() -> Vec<String> {
38    let mut points: Vec<(String, String)> = Vec::new();
39
40    // Read /proc/mounts and keep only user-visible storage mounts.
41    if let Ok(content) = fs::read_to_string("/proc/mounts") {
42        for line in content.lines() {
43            let parts: Vec<&str> = line.split_whitespace().collect();
44            if parts.len() >= 2 {
45                let mount = parts[0]; // Device
46                let mount_point = parts[1];
47
48                if mount_point == "/" || is_visible_storage_mount(mount_point) {
49                    push_preferred_mount(&mut points, mount, mount_point);
50                }
51            }
52        }
53    }
54
55    // Limit to avoid too many entries
56    points.into_iter().map(|(_, mount)| mount).take(5).collect()
57}
58
59fn get_disk_info_for_path(
60    path: &str,
61    subtitle_mode: DiskSubtitle,
62    display_mode: &str,
63) -> Option<(String, String)> {
64    let path_cstr = std::ffi::CString::new(path).ok()?;
65
66    // Use statvfs to get disk usage
67    let mut statfs: libc::statvfs = unsafe { std::mem::zeroed() };
68
69    if unsafe { libc::statvfs(path_cstr.as_ptr(), &mut statfs) } != 0 {
70        return None;
71    }
72
73    // Calculate sizes in bytes
74    let total = statfs.f_blocks as u64 * statfs.f_frsize as u64;
75    let available = statfs.f_bavail as u64 * statfs.f_frsize as u64;
76    let used = total.saturating_sub(available);
77
78    if total == 0 {
79        return None;
80    }
81
82    let percent = ((used as f64 / total as f64) * 100.0)
83        .round()
84        .clamp(0.0, 100.0) as u8;
85
86    // Format sizes in human-readable form
87    let total_h = format_size(total);
88    let used_h = format_size(used);
89
90    let usage_display = format!("{} / {}", used_h, total_h);
91    let perc_val = percent.min(100);
92
93    let final_str = render_disk_display(display_mode, &usage_display, percent, perc_val);
94
95    // Get device name from /proc/mounts
96    let subtitle = match subtitle_mode {
97        DiskSubtitle::Name => get_device_name(path),
98        DiskSubtitle::Dir => mount_dir_label(path),
99        DiskSubtitle::None => "".to_string(),
100        DiskSubtitle::Mount => path.to_string(),
101    };
102
103    Some((subtitle, final_str))
104}
105
106fn render_disk_display(spec: &str, usage_display: &str, percent: u8, perc_val: u8) -> String {
107    let parts = parse_disk_display_parts(spec).unwrap_or_else(|_| vec![DiskDisplayPart::Info]);
108
109    let mut rendered = Vec::new();
110    for part in parts {
111        match part {
112            DiskDisplayPart::Info => rendered.push(usage_display.to_string()),
113            DiskDisplayPart::Percentage => rendered.push(format!("{}%", percent)),
114            DiskDisplayPart::Bar => rendered.push(get_bar(perc_val)),
115        }
116    }
117
118    rendered.join(" ")
119}
120
121fn get_device_name(path: &str) -> String {
122    if let Ok(content) = fs::read_to_string("/proc/mounts") {
123        for line in content.lines() {
124            let parts: Vec<&str> = line.split_whitespace().collect();
125            if parts.len() >= 2 && parts[1] == path {
126                return parts[0].to_string();
127            }
128        }
129    }
130    "".to_string()
131}
132
133fn is_visible_storage_mount(mount_point: &str) -> bool {
134    matches!(
135        mount_point,
136        "/home" | "/data" | "/mnt" | "/media" | "/run/media"
137    ) || mount_point.starts_with("/home/")
138        || mount_point.starts_with("/data/")
139        || mount_point.starts_with("/mnt/")
140        || mount_point.starts_with("/media/")
141        || mount_point.starts_with("/run/media/")
142}
143
144fn push_preferred_mount(points: &mut Vec<(String, String)>, device: &str, mount_point: &str) {
145    let candidate = mount_point.to_string();
146
147    if let Some((_, current_mount)) = points
148        .iter_mut()
149        .find(|(existing_device, _)| existing_device == device)
150    {
151        if mount_priority(&candidate) > mount_priority(current_mount) {
152            *current_mount = candidate;
153        }
154        return;
155    }
156
157    points.push((device.to_string(), candidate));
158}
159
160fn mount_priority(path: &str) -> usize {
161    let trimmed = path.trim_matches('/');
162    if trimmed.is_empty() {
163        0
164    } else {
165        trimmed.split('/').count() * 10 + trimmed.len()
166    }
167}
168
169fn mount_dir_label(path: &str) -> String {
170    path.trim_end_matches('/')
171        .rsplit('/')
172        .next()
173        .filter(|segment| !segment.is_empty())
174        .unwrap_or("")
175        .to_string()
176}
177
178fn format_size(bytes: u64) -> String {
179    const KB: u64 = 1024;
180    const MB: u64 = KB * 1024;
181    const GB: u64 = MB * 1024;
182    const TB: u64 = GB * 1024;
183
184    if bytes >= TB {
185        format!("{:.1}T", bytes as f64 / TB as f64)
186    } else if bytes >= GB {
187        format!("{:.1}G", bytes as f64 / GB as f64)
188    } else if bytes >= MB {
189        format!("{:.1}M", bytes as f64 / MB as f64)
190    } else if bytes >= KB {
191        format!("{:.1}K", bytes as f64 / KB as f64)
192    } else {
193        format!("{}B", bytes)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::{
200        is_visible_storage_mount, mount_dir_label, mount_priority, push_preferred_mount,
201        render_disk_display,
202    };
203    use crate::modules::utils::get_bar;
204
205    #[test]
206    fn keeps_user_visible_storage_mounts() {
207        assert!(is_visible_storage_mount("/home"));
208        assert!(is_visible_storage_mount("/home/snape"));
209        assert!(is_visible_storage_mount("/data"));
210        assert!(is_visible_storage_mount("/mnt/usb"));
211        assert!(is_visible_storage_mount("/media/disk"));
212        assert!(is_visible_storage_mount("/run/media/snape/USB"));
213    }
214
215    #[test]
216    fn rejects_system_mounts() {
217        assert!(!is_visible_storage_mount("/"));
218        assert!(!is_visible_storage_mount("/boot"));
219        assert!(!is_visible_storage_mount("/var"));
220        assert!(!is_visible_storage_mount("/usr"));
221        assert!(!is_visible_storage_mount("/opt"));
222        assert!(!is_visible_storage_mount("/proc"));
223        assert!(!is_visible_storage_mount("/sys"));
224        assert!(!is_visible_storage_mount("/snap"));
225        assert!(!is_visible_storage_mount("/dev/sda1"));
226    }
227
228    #[test]
229    fn dir_label_uses_last_path_segment() {
230        assert_eq!(mount_dir_label("/home"), "home");
231        assert_eq!(mount_dir_label("/run/media/snape/USB"), "USB");
232        assert_eq!(mount_dir_label("/mnt/external-drive/"), "external-drive");
233        assert_eq!(mount_dir_label("/"), "");
234    }
235
236    #[test]
237    fn prefers_more_useful_mount_for_same_device() {
238        let mut points = Vec::new();
239
240        push_preferred_mount(&mut points, "/dev/nvme0n1p2", "/");
241        push_preferred_mount(&mut points, "/dev/nvme0n1p2", "/home");
242        push_preferred_mount(&mut points, "/dev/nvme0n1p2", "/home/snape");
243
244        assert_eq!(points.len(), 1);
245        assert_eq!(points[0].1, "/home/snape");
246        assert!(mount_priority("/home/snape") > mount_priority("/home"));
247        assert!(mount_priority("/home") > mount_priority("/"));
248    }
249
250    #[test]
251    fn renders_disk_display_parts_in_order() {
252        assert_eq!(render_disk_display("percentage", "1G / 2G", 50, 50), "50%");
253        assert_eq!(
254            render_disk_display("info, percentage", "1G / 2G", 50, 50),
255            "1G / 2G 50%"
256        );
257        assert_eq!(
258            render_disk_display("percentage, bar", "1G / 2G", 50, 50),
259            format!("50% {}", get_bar(50))
260        );
261    }
262}
263
264// fn parse_disk_output(
265//     stdout: &str,
266//     subtitle_mode: DiskSubtitle,
267//     display_mode: &str,
268// ) -> Vec<(String, String)> {
269//     let mut lines = stdout.lines().skip(1);
270//     let mut results = Vec::new();
271
272//     while let Some(line) = lines.next() {
273//         let parts: Vec<&str> = line.split_whitespace().filter(|s| !s.is_empty()).collect();
274
275//         if parts.len() < 6 {
276//             continue;
277//         }
278
279//         let total = parts[1];
280//         let used = parts[2];
281//         let perc = parts[4].trim_end_matches('%');
282//         let mount = parts[5];
283
284//         let usage_display = format!("{} / {}", used, total);
285//         let perc_val = perc.parse::<u8>().unwrap_or(0);
286
287//         let final_str = render_disk_display(display_mode, &usage_display, perc, perc_val);
288
289//         let subtitle = match subtitle_mode {
290//             DiskSubtitle::Name => parts[0].to_string(),
291//             DiskSubtitle::Dir => mount.split('/').last().unwrap_or("").to_string(),
292//             DiskSubtitle::None => "".to_string(),
293//             DiskSubtitle::Mount => mount.to_string(),
294//         };
295
296//         let full_subtitle = if subtitle.is_empty() {
297//             "Disk".to_string()
298//         } else {
299//             format!("Disk ({})", subtitle)
300//         };
301
302//         results.push((full_subtitle, final_str));
303//     }
304
305//     results
306// }