use std::collections::HashSet;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::{fs, io};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Size {
#[default]
Unmeasured,
Measured(u64),
}
impl Size {
#[must_use]
pub fn bytes(self) -> Option<u64> {
match self {
Self::Unmeasured => None,
Self::Measured(bytes) => Some(bytes),
}
}
#[must_use]
pub fn label(self) -> String {
match self {
Self::Measured(bytes) => human(bytes),
Self::Unmeasured => UNPRICED.to_owned(),
}
}
}
pub const UNPRICED: &str = "—";
#[must_use]
pub fn human(bytes: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
#[expect(
clippy::cast_precision_loss,
reason = "a display rounded to one decimal place has none to lose"
)]
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit + 1 < UNITS.len() {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum SizeMode {
#[default]
Skip,
Breakdown,
BreakdownUnder(PathBuf),
}
impl SizeMode {
fn prices(&self, dir: &Path) -> bool {
match self {
Self::Skip => false,
Self::Breakdown => true,
Self::BreakdownUnder(scope) => dir.starts_with(scope) || scope.starts_with(dir),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct Measurement {
pub size: Size,
pub unreadable: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct Survey {
pub size: Size,
pub nested_repo: Option<PathBuf>,
pub unreadable: Vec<PathBuf>,
pub not_crossed: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct Measurer {
mode: SizeMode,
same_file_system: bool,
}
impl Measurer {
#[must_use]
pub fn new(mode: SizeMode) -> Self {
Self {
mode,
same_file_system: true,
}
}
#[must_use]
pub fn same_file_system(mut self, same_file_system: bool) -> Self {
self.same_file_system = same_file_system;
self
}
#[must_use]
pub fn traverses(&self, dir: &Path, metadata: &fs::Metadata) -> bool {
metadata.is_dir() && self.mode.prices(dir)
}
#[must_use]
pub fn measure(&self, dir: &Path, metadata: &fs::Metadata) -> Measurement {
if !metadata.is_dir() {
return Measurement {
size: Size::Measured(allocated(metadata)),
unreadable: Vec::new(),
};
}
if !self.mode.prices(dir) {
return Measurement::default();
}
let walked = self.walk(dir, metadata, false);
Measurement {
size: Size::Measured(walked.bytes),
unreadable: walked.unreadable,
}
}
#[must_use]
pub fn survey(&self, dir: &Path, metadata: &fs::Metadata) -> Survey {
if !metadata.is_dir() {
return Survey {
size: Size::Measured(allocated(metadata)),
nested_repo: None,
unreadable: Vec::new(),
not_crossed: Vec::new(),
};
}
let walked = self.walk(dir, metadata, true);
Survey {
size: if walked.nested_repo.is_some() {
Size::Unmeasured
} else {
Size::Measured(walked.bytes)
},
nested_repo: walked.nested_repo,
unreadable: walked.unreadable,
not_crossed: walked.not_crossed,
}
}
fn walk(&self, dir: &Path, metadata: &fs::Metadata, watch_for_repos: bool) -> Walked {
let mut pass = Pass {
bytes: allocated(metadata),
boundary: device(metadata),
watch_for_repos,
..Pass::default()
};
pass.stack.push(dir.to_path_buf());
while let Some(current) = pass.stack.pop() {
match fs::read_dir(¤t) {
Ok(entries) => {
if let Some(repo) = self.absorb(¤t, entries, &mut pass) {
return pass.stopped_at(repo);
}
}
Err(_) => pass.unreadable.push(current),
}
}
pass.finished()
}
fn absorb<I>(&self, current: &Path, entries: I, pass: &mut Pass) -> Option<PathBuf>
where
I: IntoIterator<Item = io::Result<fs::DirEntry>>,
{
for entry in entries {
let Ok(entry) = entry else {
pass.unreadable.push(current.to_path_buf());
continue;
};
let path = entry.path();
if pass.watch_for_repos && entry.file_name() == OsStr::new(".git") {
return Some(current.to_path_buf());
}
let Ok(metadata) = path.symlink_metadata() else {
pass.unreadable.push(path);
continue;
};
if self.same_file_system && device(&metadata) != pass.boundary {
if pass.watch_for_repos && metadata.is_dir() {
pass.not_crossed.push(path);
}
continue;
}
if let Some(identity) = multiply_linked(&metadata) {
if !pass.linked.insert(identity) {
continue;
}
}
pass.bytes += allocated(&metadata);
if metadata.is_dir() {
pass.stack.push(path);
}
}
None
}
}
#[derive(Debug, Default)]
struct Pass {
bytes: u64,
boundary: u64,
watch_for_repos: bool,
unreadable: Vec<PathBuf>,
not_crossed: Vec<PathBuf>,
linked: HashSet<(u64, u64)>,
stack: Vec<PathBuf>,
}
impl Pass {
fn stopped_at(self, nested_repo: PathBuf) -> Walked {
Walked {
bytes: self.bytes,
nested_repo: Some(nested_repo),
unreadable: self.unreadable,
not_crossed: self.not_crossed,
}
}
fn finished(self) -> Walked {
Walked {
bytes: self.bytes,
nested_repo: None,
unreadable: self.unreadable,
not_crossed: self.not_crossed,
}
}
}
struct Walked {
bytes: u64,
nested_repo: Option<PathBuf>,
unreadable: Vec<PathBuf>,
not_crossed: Vec<PathBuf>,
}
#[cfg(unix)]
pub(crate) trait Stat {
fn is_dir(&self) -> bool;
fn dev(&self) -> u64;
fn ino(&self) -> u64;
fn nlink(&self) -> u64;
fn blocks(&self) -> u64;
}
#[cfg(unix)]
impl Stat for fs::Metadata {
fn is_dir(&self) -> bool {
Self::is_dir(self)
}
fn dev(&self) -> u64 {
std::os::unix::fs::MetadataExt::dev(self)
}
fn ino(&self) -> u64 {
std::os::unix::fs::MetadataExt::ino(self)
}
fn nlink(&self) -> u64 {
std::os::unix::fs::MetadataExt::nlink(self)
}
fn blocks(&self) -> u64 {
std::os::unix::fs::MetadataExt::blocks(self)
}
}
#[cfg(unix)]
impl Stat for cap_primitives::fs::Metadata {
fn is_dir(&self) -> bool {
Self::is_dir(self)
}
fn dev(&self) -> u64 {
cap_primitives::fs::MetadataExt::dev(self)
}
fn ino(&self) -> u64 {
cap_primitives::fs::MetadataExt::ino(self)
}
fn nlink(&self) -> u64 {
cap_primitives::fs::MetadataExt::nlink(self)
}
fn blocks(&self) -> u64 {
cap_primitives::fs::MetadataExt::blocks(self)
}
}
#[cfg(not(unix))]
pub(crate) trait Stat {
fn is_dir(&self) -> bool;
fn apparent_len(&self) -> u64;
}
#[cfg(not(unix))]
impl Stat for fs::Metadata {
fn is_dir(&self) -> bool {
Self::is_dir(self)
}
fn apparent_len(&self) -> u64 {
self.len()
}
}
#[cfg(not(unix))]
impl Stat for cap_primitives::fs::Metadata {
fn is_dir(&self) -> bool {
Self::is_dir(self)
}
fn apparent_len(&self) -> u64 {
self.len()
}
}
#[cfg(unix)]
pub(crate) fn allocated(stat: &impl Stat) -> u64 {
stat.blocks() * 512
}
#[cfg(not(unix))]
pub(crate) fn allocated(stat: &impl Stat) -> u64 {
stat.apparent_len()
}
#[cfg(unix)]
pub(crate) fn device(stat: &impl Stat) -> u64 {
stat.dev()
}
#[cfg(not(unix))]
pub(crate) fn device(_stat: &impl Stat) -> u64 {
0
}
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn identity(stat: &impl Stat) -> Option<(u64, u64)> {
Some((stat.dev(), stat.ino()))
}
#[cfg(not(unix))]
pub(crate) fn identity(_stat: &impl Stat) -> Option<(u64, u64)> {
None
}
#[cfg(unix)]
pub(crate) fn multiply_linked(stat: &impl Stat) -> Option<(u64, u64)> {
(stat.nlink() > 1 && !stat.is_dir()).then(|| (stat.dev(), stat.ino()))
}
#[cfg(not(unix))]
pub(crate) fn multiply_linked(_stat: &impl Stat) -> Option<(u64, u64)> {
None
}
#[cfg(test)]
mod tests {
use super::{Measurer, Size, SizeMode};
use std::path::Path;
use std::{fs, io};
use tempfile::TempDir;
fn write(dir: &TempDir, name: &str, bytes: usize) {
let path = dir.path().join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, vec![b'x'; bytes]).unwrap();
}
#[test]
fn a_breakdown_sums_the_whole_subtree() {
let tmp = TempDir::new().unwrap();
write(&tmp, "a/one.bin", 64 * 1024);
write(&tmp, "a/b/two.bin", 64 * 1024);
let metadata = tmp.path().symlink_metadata().unwrap();
let measured = Measurer::new(SizeMode::Breakdown).measure(tmp.path(), &metadata);
assert!(measured.size.bytes().unwrap() >= 128 * 1024, "{measured:?}");
assert!(measured.unreadable.is_empty());
}
#[test]
fn the_default_mode_reports_unmeasured_without_reading_anything() {
let tmp = TempDir::new().unwrap();
write(&tmp, "a/big.bin", 4 * 1024 * 1024);
let sealed = tmp.path().join("sealed");
fs::create_dir(&sealed).unwrap();
seal(&sealed);
let metadata = tmp.path().symlink_metadata().unwrap();
let measured = Measurer::new(SizeMode::Skip).measure(tmp.path(), &metadata);
unseal(&sealed);
assert_eq!(measured.size, Size::Unmeasured);
assert!(measured.unreadable.is_empty(), "{measured:?}");
}
#[cfg(unix)]
#[test]
fn a_breakdown_reports_what_it_could_not_read() {
let tmp = TempDir::new().unwrap();
let sealed = tmp.path().join("sealed");
fs::create_dir(&sealed).unwrap();
seal(&sealed);
if fs::read_dir(&sealed).is_ok() {
unseal(&sealed);
return; }
let metadata = tmp.path().symlink_metadata().unwrap();
let measured = Measurer::new(SizeMode::Breakdown).measure(tmp.path(), &metadata);
unseal(&sealed);
assert_eq!(measured.unreadable, [sealed]);
}
#[cfg(unix)]
#[test]
fn a_hard_linked_file_is_counted_once() {
let tmp = TempDir::new().unwrap();
write(&tmp, "a/artifact.bin", 512 * 1024);
let metadata = tmp.path().symlink_metadata().unwrap();
let measurer = Measurer::new(SizeMode::Breakdown);
let once = measurer.measure(tmp.path(), &metadata).size;
fs::hard_link(
tmp.path().join("a/artifact.bin"),
tmp.path().join("a/copy.bin"),
)
.unwrap();
let twice = measurer.measure(tmp.path(), &metadata).size;
assert_eq!(once, twice, "the second link added its blocks again");
}
#[cfg(unix)]
#[test]
fn a_symlink_out_of_the_tree_is_worth_its_own_inode_only() {
let tmp = TempDir::new().unwrap();
write(&tmp, "elsewhere/big.bin", 4 * 1024 * 1024);
let link = tmp.path().join("link");
std::os::unix::fs::symlink(tmp.path().join("elsewhere"), &link).unwrap();
let metadata = link.symlink_metadata().unwrap();
let measured = Measurer::new(SizeMode::Skip).measure(&link, &metadata);
assert!(measured.size.bytes().unwrap() < 1024 * 1024, "{measured:?}");
}
#[test]
fn a_scoped_breakdown_prices_what_is_under_the_scope_and_leaves_the_rest_alone() {
let tmp = TempDir::new().unwrap();
write(&tmp, "wanted/big.bin", 256 * 1024);
write(&tmp, "elsewhere/big.bin", 256 * 1024);
let wanted = tmp.path().join("wanted");
let elsewhere = tmp.path().join("elsewhere");
let measurer = Measurer::new(SizeMode::BreakdownUnder(wanted.clone()));
let priced = measurer.measure(&wanted, &wanted.symlink_metadata().unwrap());
assert!(priced.size.bytes().unwrap() >= 256 * 1024, "{priced:?}");
let untouched = measurer.measure(&elsewhere, &elsewhere.symlink_metadata().unwrap());
assert_eq!(untouched.size, Size::Unmeasured);
}
#[test]
fn a_scope_inside_a_claim_prices_that_claim_rather_than_nothing() {
let tmp = TempDir::new().unwrap();
write(&tmp, "claim/deep/big.bin", 256 * 1024);
let claim = tmp.path().join("claim");
let measurer = Measurer::new(SizeMode::BreakdownUnder(claim.join("deep")));
let priced = measurer.measure(&claim, &claim.symlink_metadata().unwrap());
assert!(priced.size.bytes().unwrap() >= 256 * 1024, "{priced:?}");
}
#[test]
fn a_survey_prices_the_whole_subtree_whatever_the_mode() {
let tmp = TempDir::new().unwrap();
write(&tmp, "a/one.bin", 256 * 1024);
write(&tmp, "a/b/two.bin", 256 * 1024);
let metadata = tmp.path().symlink_metadata().unwrap();
for mode in [SizeMode::Skip, SizeMode::Breakdown] {
let surveyed = Measurer::new(mode.clone()).survey(tmp.path(), &metadata);
assert!(surveyed.nested_repo.is_none());
assert!(
surveyed.size.bytes().unwrap() >= 512 * 1024,
"{mode:?}: {surveyed:?}"
);
}
}
#[test]
fn a_survey_stops_at_the_first_checkout_it_finds() {
let tmp = TempDir::new().unwrap();
let checkout = tmp.path().join("deep/checkout");
fs::create_dir_all(checkout.join(".git")).unwrap();
write(&tmp, "deep/checkout/src/main.rs", 1024);
let metadata = tmp.path().symlink_metadata().unwrap();
let surveyed = Measurer::new(SizeMode::Breakdown).survey(tmp.path(), &metadata);
assert_eq!(surveyed.nested_repo.as_deref(), Some(checkout.as_path()));
assert_eq!(surveyed.size, Size::Unmeasured);
}
#[test]
fn a_directory_that_stops_listing_part_way_through_is_reported_as_unread() {
let tmp = TempDir::new().unwrap();
let mut pass = super::Pass {
watch_for_repos: true,
..super::Pass::default()
};
let entries = vec![Err(io::Error::other("readdir gave up"))];
let stopped = Measurer::new(SizeMode::Skip).absorb(tmp.path(), entries, &mut pass);
assert!(stopped.is_none());
assert_eq!(pass.unreadable, [tmp.path().to_path_buf()]);
}
#[test]
fn a_survey_reports_a_subtree_it_will_not_cross_rather_than_passing_over_it() {
let tmp = TempDir::new().unwrap();
fs::create_dir_all(tmp.path().join("mounted/checkout/.git")).unwrap();
let here = tmp.path().symlink_metadata().unwrap();
let elsewhere = Path::new("/dev").symlink_metadata().unwrap();
if super::device(&here) == super::device(&elsewhere) {
return; }
let surveyed = Measurer::new(SizeMode::Skip).survey(tmp.path(), &elsewhere);
assert!(surveyed.nested_repo.is_none());
assert_eq!(surveyed.not_crossed, [tmp.path().join("mounted")]);
}
#[test]
fn a_dot_git_file_marks_a_checkout_just_as_a_directory_does() {
let tmp = TempDir::new().unwrap();
write(&tmp, "worktree/.git", 64);
let metadata = tmp.path().symlink_metadata().unwrap();
let surveyed = Measurer::new(SizeMode::Skip).survey(tmp.path(), &metadata);
assert_eq!(
surveyed.nested_repo.as_deref(),
Some(tmp.path().join("worktree").as_path())
);
}
#[cfg(unix)]
fn seal(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o000)).unwrap();
}
#[cfg(unix)]
fn unseal(dir: &std::path::Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[cfg(not(unix))]
fn seal(_dir: &std::path::Path) {}
#[cfg(not(unix))]
fn unseal(_dir: &std::path::Path) {}
}