refactor(cache): extract dir_size + prune_empty_dirs into fs_utils

Lift the duplicated `dir_size` walker and the empty-directory upward-prune
loop out of cache/offline.rs and cache/hot.rs into a shared
cache/fs_utils.rs module. Behaviour-preserving: same boundary semantics,
same skip-on-error policy, same `remove_dir`-only pruning (never
remove_dir_all).

Net delta: -76 LOC across cache/, single source of truth for cache cleanup.
This commit is contained in:
Psychotoxical
2026-05-08 11:40:37 +02:00
parent 8b781a848d
commit 480e32ba04
4 changed files with 61 additions and 84 deletions
+53
View File
@@ -0,0 +1,53 @@
use std::path::Path;
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
pub(crate) fn dir_size_recursive(root: &Path) -> u64 {
if !root.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
///
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub(crate) fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
if std::fs::remove_dir(&dir).is_err() {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
}
}
+3 -42
View File
@@ -172,31 +172,8 @@ pub(crate) async fn promote_stream_cache_to_hot_cache(
#[tauri::command]
pub(crate) async fn get_hot_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() {
return 0;
}
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
resolve_hot_cache_root(custom_dir, &app)
.map(|root| dir_size(root))
.map(|root| super::fs_utils::dir_size_recursive(&root))
.unwrap_or(0)
}
@@ -223,24 +200,8 @@ pub(crate) async fn delete_hot_cache_track(
);
let boundary = resolve_hot_cache_root(custom_dir, &app)?;
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break;
}
if std::fs::remove_dir(&dir).is_err() {
break;
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
if let Some(parent) = file_path.parent() {
super::fs_utils::prune_empty_dirs_up_to(parent, &boundary);
}
Ok(())
+1
View File
@@ -1,5 +1,6 @@
use super::*;
mod fs_utils;
mod offline;
mod downloads;
mod hot;
+4 -42
View File
@@ -145,37 +145,16 @@ pub(crate) async fn download_track_offline(
/// Returns the total size in bytes of all files in the offline cache directory (and optional custom dir).
#[tauri::command]
pub(crate) async fn get_offline_cache_size(custom_dir: Option<String>, app: tauri::AppHandle) -> u64 {
fn dir_size(root: std::path::PathBuf) -> u64 {
if !root.exists() { return 0; }
let mut total: u64 = 0;
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
let default_dir = match app.path().app_data_dir() {
Ok(d) => d.join("psysonic-offline"),
Err(_) => return 0,
};
let mut total = dir_size(default_dir);
let mut total = super::fs_utils::dir_size_recursive(&default_dir);
if let Some(cd) = custom_dir {
let custom = std::path::PathBuf::from(cd);
if custom != std::path::PathBuf::from("") {
total += dir_size(custom);
total += super::fs_utils::dir_size_recursive(&custom);
}
}
total
@@ -208,25 +187,8 @@ pub(crate) async fn delete_offline_track(
.join("psysonic-offline")
};
// Walk upward, pruning directories that have become empty.
// Stops as soon as a non-empty directory or the boundary is reached.
let mut current = file_path.parent().map(|p| p.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(&boundary) {
break;
}
match std::fs::read_dir(&dir) {
Ok(mut entries) => {
if entries.next().is_some() {
break; // Directory still has contents — stop pruning.
}
if std::fs::remove_dir(&dir).is_err() {
break; // Could not remove (e.g. permissions) — stop.
}
current = dir.parent().map(|p| p.to_path_buf());
}
Err(_) => break,
}
if let Some(parent) = file_path.parent() {
super::fs_utils::prune_empty_dirs_up_to(parent, &boundary);
}
Ok(())