diskr 0.1.17

Lightweight terminal file explorer and disk/storage manager for macOS
use anyhow::{bail, Context, Result};
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::Command;

#[derive(Clone, Debug)]
pub struct SpaceReport {
    pub path: PathBuf,
    pub mount: PathBuf,
    pub device: String,
    pub fs_type: String,
    pub total: u64,
    pub used: u64,
    pub free: u64,
    pub available: u64,
    pub local_snapshots: SnapshotListing,
    pub apfs_container: Option<ApfsContainer>,
}

impl SpaceReport {
    pub fn unavailable_free(&self) -> u64 {
        self.free.saturating_sub(self.available)
    }
}

#[derive(Clone, Debug, Default)]
pub struct SnapshotListing {
    pub names: Vec<String>,
    pub error: Option<String>,
}

#[derive(Clone, Debug)]
pub struct ApfsContainer {
    pub reference: String,
    pub size: u64,
    pub free: u64,
}

#[derive(Clone, Debug)]
pub struct ThinResult {
    pub path: PathBuf,
    pub requested_bytes: u64,
    pub stdout: String,
    pub stderr: String,
}

pub fn report_for_path(path: &Path) -> Result<SpaceReport> {
    if !path.exists() {
        bail!("path does not exist: {}", path.display());
    }

    let stat = statfs_for_path(path)?;
    let local_snapshots = list_local_snapshots(path);
    let apfs_container = if stat.fs_type == "apfs" {
        apfs_container_for_mount(&stat.mount).ok().flatten()
    } else {
        None
    };

    Ok(SpaceReport {
        path: path.to_path_buf(),
        mount: stat.mount,
        device: stat.device,
        fs_type: stat.fs_type,
        total: stat.total,
        used: stat.used,
        free: stat.free,
        available: stat.available,
        local_snapshots,
        apfs_container,
    })
}

pub fn thin_local_snapshots(path: &Path, bytes: u64) -> Result<ThinResult> {
    if bytes == 0 {
        bail!("snapshot thin amount must be greater than zero");
    }
    let output = Command::new("tmutil")
        .arg("thinlocalsnapshots")
        .arg(path)
        .arg(bytes.to_string())
        .arg("4")
        .output()
        .context("run tmutil thinlocalsnapshots")?;

    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    if !output.status.success() {
        let message = if stderr.is_empty() { &stdout } else { &stderr };
        bail!("tmutil thinlocalsnapshots failed: {message}");
    }

    Ok(ThinResult {
        path: path.to_path_buf(),
        requested_bytes: bytes,
        stdout,
        stderr,
    })
}

pub fn parse_byte_size(input: &str) -> Result<u64> {
    let input = input.trim();
    if input.is_empty() {
        bail!("size cannot be empty");
    }

    let split_at = input
        .find(|ch: char| !(ch.is_ascii_digit() || ch == '.'))
        .unwrap_or(input.len());
    let (number, unit) = input.split_at(split_at);
    if number.is_empty() || number == "." || number.matches('.').count() > 1 {
        bail!("invalid size: {input}");
    }

    let value = number
        .parse::<f64>()
        .with_context(|| format!("invalid size: {input}"))?;
    if !value.is_finite() || value <= 0.0 {
        bail!("size must be greater than zero");
    }

    let multiplier = match unit.trim().to_ascii_lowercase().as_str() {
        "" | "b" => 1.0,
        "k" | "kb" | "kib" => 1024.0,
        "m" | "mb" | "mib" => 1024.0 * 1024.0,
        "g" | "gb" | "gib" => 1024.0 * 1024.0 * 1024.0,
        "t" | "tb" | "tib" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
        "p" | "pb" | "pib" => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
        _ => bail!("unknown size unit: {unit}"),
    };
    let bytes = value * multiplier;
    if bytes > u64::MAX as f64 {
        bail!("size is too large");
    }
    Ok(bytes.round() as u64)
}

struct StatfsInfo {
    mount: PathBuf,
    device: String,
    fs_type: String,
    total: u64,
    used: u64,
    free: u64,
    available: u64,
}

fn statfs_for_path(path: &Path) -> Result<StatfsInfo> {
    let c_path = CString::new(path.as_os_str().as_bytes())
        .with_context(|| format!("path contains interior NUL: {}", path.display()))?;
    let mut stat = std::mem::MaybeUninit::<libc::statfs>::uninit();
    let rc = unsafe { libc::statfs(c_path.as_ptr(), stat.as_mut_ptr()) };
    if rc != 0 {
        return Err(std::io::Error::last_os_error())
            .with_context(|| format!("statfs {}", path.display()));
    }
    let stat = unsafe { stat.assume_init() };
    let block_size = u64::from(stat.f_bsize);
    let total = stat.f_blocks.saturating_mul(block_size);
    let free = stat.f_bfree.saturating_mul(block_size);
    let available = stat.f_bavail.saturating_mul(block_size);

    Ok(StatfsInfo {
        mount: PathBuf::from(c_char_array_to_string(&stat.f_mntonname)),
        device: c_char_array_to_string(&stat.f_mntfromname),
        fs_type: c_char_array_to_string(&stat.f_fstypename),
        total,
        used: total.saturating_sub(free),
        free,
        available,
    })
}

fn list_local_snapshots(path: &Path) -> SnapshotListing {
    match Command::new("tmutil")
        .arg("listlocalsnapshots")
        .arg(path)
        .output()
    {
        Ok(output) if output.status.success() => SnapshotListing {
            names: parse_tmutil_snapshot_names(&String::from_utf8_lossy(&output.stdout)),
            error: None,
        },
        Ok(output) => {
            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
            let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
            SnapshotListing {
                names: Vec::new(),
                error: Some(if stderr.is_empty() { stdout } else { stderr }),
            }
        }
        Err(err) => SnapshotListing {
            names: Vec::new(),
            error: Some(err.to_string()),
        },
    }
}

fn apfs_container_for_mount(mount: &Path) -> Result<Option<ApfsContainer>> {
    let output = Command::new("diskutil")
        .arg("info")
        .arg("-plist")
        .arg(mount)
        .output()
        .context("run diskutil info -plist")?;
    if !output.status.success() {
        return Ok(None);
    }

    parse_apfs_container_plist(&output.stdout)
}

fn parse_tmutil_snapshot_names(output: &str) -> Vec<String> {
    output
        .lines()
        .map(str::trim)
        .filter(|line| !line.is_empty())
        .filter(|line| !line.starts_with("Snapshots for "))
        .map(ToOwned::to_owned)
        .collect()
}

fn c_char_array_to_string(chars: &[libc::c_char]) -> String {
    if chars.is_empty() || chars[0] == 0 {
        return String::new();
    }
    unsafe { std::ffi::CStr::from_ptr(chars.as_ptr()) }
        .to_string_lossy()
        .into_owned()
}

fn parse_apfs_container_plist(plist: &[u8]) -> Result<Option<ApfsContainer>> {
    let plist = std::str::from_utf8(plist).context("diskutil plist was not utf-8")?;
    let Some(reference) = extract_plist_string(plist, "APFSContainerReference") else {
        return Ok(None);
    };
    let Some(size) = extract_plist_u64(plist, "APFSContainerSize")? else {
        return Ok(None);
    };
    let Some(free) = extract_plist_u64(plist, "APFSContainerFree")? else {
        return Ok(None);
    };

    Ok(Some(ApfsContainer {
        reference,
        size,
        free,
    }))
}

fn extract_plist_string(plist: &str, key: &str) -> Option<String> {
    extract_plist_scalar(plist, key, "string").map(ToOwned::to_owned)
}

fn extract_plist_u64(plist: &str, key: &str) -> Result<Option<u64>> {
    let Some(raw) = extract_plist_scalar(plist, key, "integer") else {
        return Ok(None);
    };
    raw.parse::<u64>()
        .with_context(|| format!("invalid integer value for plist key {key}"))
        .map(Some)
}

fn extract_plist_scalar<'a>(plist: &'a str, key: &str, tag: &str) -> Option<&'a str> {
    let key_marker = format!("<key>{key}</key>");
    let start = plist.find(&key_marker)? + key_marker.len();
    let tail = &plist[start..];
    let open = format!("<{tag}>");
    let close = format!("</{tag}>");
    let value_start = tail.find(&open)? + open.len();
    let tail = &tail[value_start..];
    let value_end = tail.find(&close)?;
    Some(&tail[..value_end])
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_byte_size_accepts_binary_units() {
        assert_eq!(parse_byte_size("1024").unwrap(), 1024);
        assert_eq!(parse_byte_size("1K").unwrap(), 1024);
        assert_eq!(parse_byte_size("1.5G").unwrap(), 1_610_612_736);
        assert_eq!(parse_byte_size("2 TiB").unwrap(), 2_199_023_255_552);
    }

    #[test]
    fn parse_byte_size_rejects_invalid_values() {
        assert!(parse_byte_size("").is_err());
        assert!(parse_byte_size("0").is_err());
        assert!(parse_byte_size("12XB").is_err());
        assert!(parse_byte_size("..1G").is_err());
    }

    #[test]
    fn parses_tmutil_snapshot_names() {
        let output = "\
Snapshots for volume group containing disk /:
com.apple.TimeMachine.2026-06-08-120000.local
com.apple.os.update-MSUPrepareUpdate
";

        assert_eq!(
            parse_tmutil_snapshot_names(output),
            vec![
                "com.apple.TimeMachine.2026-06-08-120000.local",
                "com.apple.os.update-MSUPrepareUpdate"
            ]
        );
    }

    #[test]
    fn parse_apfs_container_plist_extracts_required_fields() {
        let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>APFSContainerFree</key>
    <integer>123</integer>
    <key>APFSContainerReference</key>
    <string>disk3</string>
    <key>APFSContainerSize</key>
    <integer>456</integer>
</dict>
</plist>
"#;

        let container = parse_apfs_container_plist(plist).unwrap().unwrap();
        assert_eq!(container.reference, "disk3");
        assert_eq!(container.free, 123);
        assert_eq!(container.size, 456);
    }

    #[test]
    fn parse_apfs_container_plist_returns_none_when_fields_are_missing() {
        let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>FilesystemType</key>
    <string>apfs</string>
</dict>
</plist>
"#;

        assert!(parse_apfs_container_plist(plist).unwrap().is_none());
    }

    #[test]
    fn parse_apfs_container_plist_rejects_invalid_integer_fields() {
        let plist = br#"<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0">
<dict>
    <key>APFSContainerFree</key>
    <integer>nope</integer>
    <key>APFSContainerReference</key>
    <string>disk3</string>
    <key>APFSContainerSize</key>
    <integer>456</integer>
</dict>
</plist>
"#;

        assert!(parse_apfs_container_plist(plist).is_err());
    }
}