Skip to main content

sioul_sync/
disk.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The room left on a disk, so fetching older mail, or your other devices'
5//! notes, never fills it.
6
7use std::path::Path;
8
9/// Bytes this user may still write on the disk holding `path`, and the
10/// disk's size; None when the system does not say.
11pub fn space(path: &Path) -> Option<(u64, u64)> {
12    // The folder may not exist yet: its nearest parent that does.
13    let existing = path.ancestors().find(|p| p.exists())?;
14    platform(existing)
15}
16
17#[cfg(unix)]
18fn platform(path: &Path) -> Option<(u64, u64)> {
19    let stat = rustix::fs::statvfs(path).ok()?;
20    let block = stat.f_frsize;
21    Some((stat.f_bavail.saturating_mul(block), stat.f_blocks.saturating_mul(block)))
22}
23
24#[cfg(windows)]
25fn platform(path: &Path) -> Option<(u64, u64)> {
26    use std::os::windows::ffi::OsStrExt;
27    let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
28    let (mut free, mut total) = (0u64, 0u64);
29    // SAFETY: a nul-terminated path, and two outputs that live through the call.
30    let done = unsafe { windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(windows::core::PCWSTR(wide.as_ptr()), Some(&mut free), Some(&mut total), None) };
31    done.ok().map(|()| (free, total))
32}
33
34#[cfg(not(any(unix, windows)))]
35fn platform(_path: &Path) -> Option<(u64, u64)> {
36    None
37}
38
39/// What stays free whatever comes: 5 GB, or a twentieth of the disk if more.
40pub fn reserve(total: u64) -> u64 {
41    (5u64 << 30).max(total / 20)
42}
43
44/// What the sharing leaves free when it writes your other devices' notes and
45/// papers, or keeps earlier versions: a quarter of a GB, or a hundredth of
46/// the disk if more. Less than the mail's reserve: these are yours, asked
47/// for; enough that the doses' record and Sioul's memory are always written.
48pub fn kept_free(total: u64) -> u64 {
49    (256u64 << 20).max(total / 100)
50}
51
52#[cfg(test)]
53thread_local! {
54    /// The room a test says there is, in its thread (enough, unless it says
55    /// otherwise: a test never depends on how full the disk running it is); none: the disk's own.
56    pub(crate) static ROOM: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(Some(u64::MAX)) };
57}
58
59/// Whether `bytes` more fit on the disk holding `path`, `kept_free` left free;
60/// yes when the system does not say.
61pub fn fits(path: &Path, bytes: u64) -> bool {
62    #[cfg(test)]
63    if let Some(room) = ROOM.with(std::cell::Cell::get) {
64        return room >= bytes;
65    }
66    space(path).is_none_or(|(free, total)| free.saturating_sub(kept_free(total)) >= bytes)
67}
68
69/// Bytes older mail may take now on the disk holding `path`: what is free
70/// beyond the reserve; everything when the system does not say.
71pub fn room(path: &Path) -> u64 {
72    space(path).map_or(u64::MAX, |(free, total)| free.saturating_sub(reserve(total)))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn the_reserve_grows_with_the_disk() {
81        assert_eq!(reserve(64 << 30), 5 << 30);
82        assert_eq!(reserve(1000 << 30), 50 << 30);
83        let (free, total) = space(&std::env::temp_dir().join("sioul-not-there/at-all")).expect("the system says");
84        assert!(total >= free && total > 0);
85    }
86}