Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ exclude = ["gui"]
resolver = "2"

[workspace.package]
version = "0.11.1"
version = "0.12.0"
edition = "2021"
license = "MIT"
authors = ["kingchenc"]
Expand Down
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ cargo install --path crates/diskghost-cli
diskghost scan "C:\Users\me\Desktop" --top 20

# Scan options (an invalid exclude glob is an error, never silently skipped)
diskghost scan . --exclude node_modules --exclude "*.tmp" --max-depth 3 --follow-symlinks
diskghost scan . --exclude node_modules --exclude "*.tmp" --follow-symlinks

# Read only 2 levels deep: faster, but folders below are not measured
diskghost scan . --max-depth 2

# Duplicates (ignore anything under 5 MB)
diskghost dupes "D:\Media" --min-mb 5
Expand All @@ -110,6 +113,13 @@ diskghost diff before.dgs after.dgs # compare two saved sna
diskghost diff before.dgs after.dgs --at "C:\Users\me\Downloads" # ...at a sub-folder
```

`--max-depth N` stops the walk N levels below the root (1 = only the
root's own entries). Folders it cuts off are never shown as empty: one that
was not read at all reads `? … (not measured)`, one read only partly
`≥ 1.2 GB` (a lower bound), and the totals carry `≥` too. Such a scan cannot
be saved or compared (`--save` / `--since`), since every folder it skipped
would look emptied. For exact numbers, scan without it.

A scheduled task (cron, Task Scheduler) that runs
`diskghost scan <dir> --since last.dgs --save last.dgs --json` gives you a
daily "what grew" report with nothing else to set up. Snapshots saved by
Expand Down Expand Up @@ -138,6 +148,11 @@ stderr with a non-zero exit code and leave stdout empty; only `rm` and
`dupes --reclaim` still print their report when some files could not be
removed, with the failures in its `errors`.

A scan cut short by `--max-depth` (or cancelled) has `"complete": false` at
the top and on every folder in `children` that was not fully read; its
`size` and `file_count` are then lower bounds, 0 for a folder that was not
read at all. Complete entries carry no `complete` field.

Perfect for wiring into an automation/agent that decides what to clean.

## Desktop app (GUI)
Expand Down Expand Up @@ -174,7 +189,9 @@ A modern Tauri UI lives in `gui/`:
list length, max depth, symlinks, parallel scans, snapshots kept per folder,
duplicate minimum size, Recycle Bin vs. permanent delete, window size at start
(1440p if it fits the screen, otherwise maximized; or 1080p / 4K / maximized),
language. The version is shown next to the name.
language. The version is shown next to the name. With a max depth set,
folders below it show "not measured" (or `≥` when partly read) instead of a
size, and such a scan is neither kept as a snapshot nor compared.
- **Find duplicates** — every group is byte-identical content (BLAKE3 over the
whole file), whatever the names. Copies inside installed programs and games
(Steam, Epic, Battle.net, Program Files, …; DLLs, game archives) are there on
Expand Down
47 changes: 37 additions & 10 deletions crates/diskghost-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ enum Command {
#[command(flatten)]
walk: WalkArgs,
/// Save the scan as a snapshot (every folder and file, compressed) for a
/// later `--since` or `diff`.
#[arg(long, value_name = "FILE")]
/// later `--since` or `diff`. Needs the whole tree: not with --max-depth.
#[arg(long, value_name = "FILE", conflicts_with = "max_depth")]
save: Option<PathBuf>,
/// Compare against a snapshot saved earlier: what grew, shrank,
/// appeared or vanished since then.
#[arg(long, value_name = "FILE")]
/// appeared or vanished since then. Not with --max-depth.
#[arg(long, value_name = "FILE", conflicts_with = "max_depth")]
since: Option<PathBuf>,
},
/// Compare two snapshots saved with `scan --save`.
Expand Down Expand Up @@ -158,8 +158,10 @@ struct WalkArgs {
/// e.g. --exclude "*.tmp" --exclude node_modules
#[arg(long)]
exclude: Vec<String>,
/// Limit recursion depth below the root (1 = the root's direct children).
#[arg(long)]
/// Read only this many levels below the root (1 = the root's own entries).
/// Folders the limit cuts off are not measured: their sizes are reported
/// as lower bounds, or as not measured at all.
#[arg(long, value_parser = clap::builder::RangedU64ValueParser::<usize>::new().range(1..))]
max_depth: Option<usize>,
/// Follow symbolic links / junctions.
#[arg(long)]
Expand Down Expand Up @@ -274,7 +276,7 @@ fn main() -> ExitCode {
};
println!("{}", serde_json::to_string_pretty(&out).unwrap());
} else {
print_scan(&report);
print_scan(&report, walk.max_depth);
if let Some(d) = &changes {
println!();
print_diff(d);
Expand Down Expand Up @@ -527,10 +529,21 @@ fn fmt_duration(d: std::time::Duration) -> String {
}
}

fn print_scan(r: &ScanReport) {
/// A folder's size as far as it is known: `≥` when part of it was not read,
/// `?` when none of it was.
fn dir_size_text(size: u64, complete: bool) -> String {
match (complete, size) {
(true, _) => human_size(size),
(false, 0) => "?".to_string(),
(false, _) => format!("≥ {}", human_size(size)),
}
}

fn print_scan(r: &ScanReport, max_depth: Option<usize>) {
println!("Scan of {}", r.root.display());
let at_least = if r.complete { "" } else { "≥ " };
println!(
" total: {} across {} files in {} dirs",
" total: {at_least}{} across {at_least}{} files in {at_least}{} dirs",
human_size(r.total_size),
r.total_files,
r.total_dirs
Expand All @@ -550,12 +563,26 @@ fn print_scan(r: &ScanReport) {
}
if r.cancelled {
println!(" CANCELLED: partial result, the numbers cover only what was scanned");
} else if !r.complete {
let limit = max_depth.map_or(String::new(), |m| format!(" (--max-depth {m})"));
println!(
" DEPTH LIMIT{limit}: deeper folders were not read; ≥ marks a lower bound, ? a folder not measured"
);
}
println!();

println!("Biggest sub-folders:");
for d in &r.children {
println!(" {:>10} {}", human_size(d.size), d.path.display());
let note = if !d.complete && d.size == 0 {
" (not measured)"
} else {
""
};
println!(
" {:>10} {}{note}",
dir_size_text(d.size, d.complete),
d.path.display()
);
}
if r.root_files_count > 0 {
println!(
Expand Down
142 changes: 142 additions & 0 deletions crates/diskghost-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -437,3 +437,145 @@ fn snapshot_errors_are_reported() {

std::fs::remove_dir_all(&d).ok();
}

/// root/top.bin, root/a/1.bin, root/a/x/2.bin and an empty root/c.
fn depth_fixture(label: &str) -> std::path::PathBuf {
let d = scratch(label);
std::fs::create_dir_all(d.join("a/x")).unwrap();
std::fs::create_dir_all(d.join("c")).unwrap();
std::fs::write(d.join("top.bin"), [0u8; 100]).unwrap();
std::fs::write(d.join("a/1.bin"), [0u8; 100]).unwrap();
std::fs::write(d.join("a/x/2.bin"), [0u8; 100]).unwrap();
d
}

#[test]
fn max_depth_never_shows_unread_folders_as_empty() {
let d = depth_fixture("depth");
let dir = d.to_str().unwrap();
let run = |args: &[&str]| {
Command::new(bin())
.args(["scan", dir])
.args(args)
.output()
.expect("run diskghost")
};
let child = |v: &serde_json::Value, name: &str| {
v["children"]
.as_array()
.unwrap()
.iter()
.find(|c| c["path"].as_str().unwrap().ends_with(name))
.cloned()
.unwrap()
};

// A full scan is complete: no flag anywhere.
let v = json_only(&run(&["--json"]));
assert!(v.get("complete").is_none(), "{v}");
assert!(v["children"]
.as_array()
.unwrap()
.iter()
.all(|c| c.get("complete").is_none()));
assert_eq!(v["total_files"], 3);

// Depth 1: a and c were not read — not measured, never "empty".
let v = json_only(&run(&["--max-depth", "1", "--json"]));
assert_eq!(v["complete"], false);
assert_eq!(v["total_files"], 1);
for name in ["a", "c"] {
let c = child(&v, name);
assert_eq!(c["complete"], false, "{c}");
assert_eq!(c["size"], 0);
}
let out = run(&["--max-depth", "1"]);
assert!(out.status.success());
let s = String::from_utf8_lossy(&out.stdout);
assert!(s.contains("total: ≥ "), "{s}");
assert!(s.contains("DEPTH LIMIT (--max-depth 1)"), "{s}");
for name in ["a", "c"] {
let line = s
.lines()
.find(|l| l.trim_end().ends_with(&format!("{name} (not measured)")))
.unwrap_or_else(|| panic!("no unmeasured line for {name}:\n{s}"));
assert!(line.trim_start().starts_with("? "), "{line}");
assert!(!line.contains(" B "), "{line}");
}

// Depth 2: a's own file is counted, a/x is not — a is a lower bound;
// c was read and really is empty.
let v = json_only(&run(&["--max-depth", "2", "--json"]));
let a = child(&v, "a");
assert_eq!(a["complete"], false);
assert_eq!(a["file_count"], 1);
assert!(a["size"].as_u64().unwrap() > 0);
assert!(child(&v, "c").get("complete").is_none());
let s = String::from_utf8_lossy(&run(&["--max-depth", "2"]).stdout).into_owned();
let line = s
.lines()
.find(|l| {
l.trim_end()
.ends_with(&format!("{}a", std::path::MAIN_SEPARATOR))
})
.unwrap_or_else(|| panic!("no line for a:\n{s}"));
assert!(line.trim_start().starts_with("≥ "), "{line}");

// A limit deeper than the tree reads everything.
let v = json_only(&run(&["--max-depth", "3", "--json"]));
assert!(v.get("complete").is_none(), "{v}");
assert_eq!(v["total_files"], 3);

std::fs::remove_dir_all(&d).ok();
}

#[test]
fn max_depth_is_refused_where_it_would_mislead() {
let d = depth_fixture("depthbad");
let dir = d.to_str().unwrap();
// A valid snapshot to compare against, from a full scan.
let full = d.join("full.dgs");
let out = Command::new(bin())
.args(["scan", dir, "--save", full.to_str().unwrap()])
.output()
.expect("run diskghost");
assert!(out.status.success(), "{out:?}");
let snap = d.join("s.dgs");
for (args, why) in [
(
vec![
"scan",
dir,
"--max-depth",
"1",
"--save",
snap.to_str().unwrap(),
],
"cannot be used with",
),
(
vec![
"scan",
dir,
"--max-depth",
"1",
"--since",
full.to_str().unwrap(),
],
"cannot be used with",
),
(vec!["scan", dir, "--max-depth", "0"], "invalid value '0'"),
(vec!["dupes", dir, "--max-depth", "0"], "invalid value '0'"),
] {
let out = Command::new(bin())
.args(&args)
.output()
.expect("run diskghost");
assert!(!out.status.success(), "{args:?} should fail");
assert!(out.stdout.is_empty(), "{args:?}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains(why), "{args:?}: {err}");
}
assert!(!snap.exists(), "no snapshot of a partial scan");
std::fs::remove_dir_all(&d).ok();
}
19 changes: 19 additions & 0 deletions crates/diskghost-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,20 @@ pub struct DirSize {
pub path: PathBuf,
pub size: u64,
pub file_count: u64,
/// False if part of the folder was never read (it lies at or below the
/// depth limit, or the walk was cancelled): `size` and `file_count` are
/// then lower bounds, and 0 when the folder itself was not read. Written
/// only when false.
#[serde(default = "yes", skip_serializing_if = "is_true")]
pub complete: bool,
}

fn yes() -> bool {
true
}

fn is_true(b: &bool) -> bool {
*b
}

/// Bytes and file count of one file extension.
Expand Down Expand Up @@ -212,6 +226,11 @@ pub struct ScanReport {
/// above then covers only the part of the tree seen before the stop.
#[serde(default)]
pub cancelled: bool,
/// False if some folder below was never read (depth limit, or a
/// cancelled walk): every number above is then a lower bound. Written
/// only when false.
#[serde(default = "yes", skip_serializing_if = "is_true")]
pub complete: bool,
}

/// A group of byte-identical files (hard links to the same physical file are
Expand Down
Loading
Loading