cargo-reclaim 0.2.1

Safe Cargo cleanup for target directories, stale artifacts, and Cargo home caches
Documentation
use std::path::{Path, PathBuf};
use std::time::Duration;

use super::error::ConfigError;

pub(super) fn parse_config_duration(value: &str) -> Result<Duration, ConfigError> {
    let trimmed = value.trim();
    let Some((amount, unit)) = trimmed.split_once(' ') else {
        return parse_compact_duration(trimmed);
    };
    let amount = parse_positive_amount(amount, value)?;
    let seconds = match unit {
        "second" | "seconds" => amount,
        "minute" | "minutes" => amount.saturating_mul(60),
        "hour" | "hours" => amount.saturating_mul(60 * 60),
        "day" | "days" => amount.saturating_mul(24 * 60 * 60),
        _ => return Err(ConfigError::InvalidDuration(value.to_string())),
    };
    Ok(Duration::from_secs(seconds))
}

fn parse_compact_duration(value: &str) -> Result<Duration, ConfigError> {
    let Some((number, suffix)) = value.split_at_checked(value.len().saturating_sub(1)) else {
        return Err(ConfigError::InvalidDuration(value.to_string()));
    };
    let amount = parse_positive_amount(number, value)?;
    let seconds = match suffix {
        "s" => amount,
        "m" => amount.saturating_mul(60),
        "h" => amount.saturating_mul(60 * 60),
        "d" => amount.saturating_mul(24 * 60 * 60),
        _ => return Err(ConfigError::InvalidDuration(value.to_string())),
    };
    Ok(Duration::from_secs(seconds))
}

fn parse_positive_amount(value: &str, original: &str) -> Result<u64, ConfigError> {
    let amount = value
        .parse::<u64>()
        .map_err(|_| ConfigError::InvalidDuration(original.to_string()))?;
    if amount == 0 {
        return Err(ConfigError::InvalidDuration(original.to_string()));
    }
    Ok(amount)
}

pub(super) fn parse_config_size(value: &str) -> Result<u64, ConfigError> {
    let trimmed = value.trim();
    let (amount, unit) = split_amount_and_unit(trimmed)
        .ok_or_else(|| ConfigError::InvalidSize(value.to_string()))?;
    let amount = amount
        .parse::<u64>()
        .map_err(|_| ConfigError::InvalidSize(value.to_string()))?;
    if amount == 0 {
        return Err(ConfigError::InvalidSize(value.to_string()));
    }
    let multiplier = match unit {
        "B" | "byte" | "bytes" => 1_u64,
        "KiB" => 1024,
        "MiB" => 1024_u64.saturating_pow(2),
        "GiB" => 1024_u64.saturating_pow(3),
        "TiB" => 1024_u64.saturating_pow(4),
        _ => return Err(ConfigError::InvalidSize(value.to_string())),
    };
    amount
        .checked_mul(multiplier)
        .ok_or_else(|| ConfigError::InvalidSize(value.to_string()))
}

pub(super) fn parse_config_percentage_basis_points(value: &str) -> Result<u16, ConfigError> {
    let trimmed = value.trim();
    let without_percent = trimmed
        .strip_suffix('%')
        .map(str::trim)
        .ok_or_else(|| ConfigError::InvalidPercentage(value.to_string()))?;
    let basis_points = parse_decimal_percentage_basis_points(without_percent)
        .ok_or_else(|| ConfigError::InvalidPercentage(value.to_string()))?;
    if basis_points == 0 || basis_points > 10_000 {
        return Err(ConfigError::InvalidPercentage(value.to_string()));
    }
    u16::try_from(basis_points).map_err(|_| ConfigError::InvalidPercentage(value.to_string()))
}

fn parse_decimal_percentage_basis_points(value: &str) -> Option<u32> {
    if value.is_empty() {
        return None;
    }
    let (whole, fractional) = value.split_once('.').unwrap_or((value, ""));
    if whole.is_empty()
        || !whole.chars().all(|character| character.is_ascii_digit())
        || !fractional
            .chars()
            .all(|character| character.is_ascii_digit())
        || fractional.len() > 2
    {
        return None;
    }
    let whole_basis_points = whole.parse::<u32>().ok()?.checked_mul(100)?;
    let fractional_basis_points = match fractional.len() {
        0 => 0,
        1 => fractional.parse::<u32>().ok()?.checked_mul(10)?,
        2 => fractional.parse::<u32>().ok()?,
        _ => return None,
    };
    whole_basis_points.checked_add(fractional_basis_points)
}

fn split_amount_and_unit(value: &str) -> Option<(&str, &str)> {
    if let Some((amount, unit)) = value.split_once(' ') {
        let unit = unit.trim();
        if amount.is_empty() || unit.is_empty() || unit.contains(' ') {
            return None;
        }
        return Some((amount, unit));
    }

    let split_at = value.find(|character: char| !character.is_ascii_digit())?;
    let (amount, unit) = value.split_at(split_at);
    if amount.is_empty() || unit.is_empty() {
        return None;
    }
    Some((amount, unit))
}

pub(super) fn expand_home(path: PathBuf) -> PathBuf {
    let text = path.to_string_lossy();
    let Some(rest) = text.strip_prefix("~/") else {
        return path;
    };
    let Some(home) = home_dir() else {
        return path;
    };
    home.join(rest)
}

pub(super) fn resolve_config_path(path: PathBuf, relative_base: Option<&Path>) -> PathBuf {
    let expanded = expand_home(path);
    if expanded.is_absolute() {
        return expanded;
    }
    relative_base
        .map(|base| base.join(&expanded))
        .unwrap_or(expanded)
}

fn home_dir() -> Option<PathBuf> {
    std::env::var_os("HOME")
        .or_else(|| std::env::var_os("USERPROFILE"))
        .map(PathBuf::from)
}