Port treegen to a cargo example

This commit is contained in:
Andy Dixon
2026-10-08 19:54:56 +01:00
parent ce8acf0ade
commit 01785850bf
+210
View File
@@ -0,0 +1,210 @@
//! Synthetic directory-tree generator for testing and benchmarking mapsize:
//!
//! cargo run --release --example treegen -- --out DIR [flags]
//!
//! Flags (`-flag` or `--flag`, value as `=V` or a separate argument):
//! --out DIR output directory (required, must not exist or be empty)
//! --directories N number of directories (1000)
//! --files N number of files (10000)
//! --max-depth N maximum directory depth (8)
//! --sparse make some large files sparse
//! --unicode use Unicode and unusual (but safe) names
//! --hostile include names with control characters/escape sequences (Unix only)
//! --max-size N largest regular file in bytes; sizes are log-uniform, content is written (1048576)
//! --seed N random seed; output is deterministic per seed (1)
//! --hotspots N directories that receive a large share of files (3)
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::PathBuf;
use std::process::exit;
use std::time::Instant;
const WORDS: &[&str] = &[
"data",
"cache",
"backup",
"photos",
"src",
"build",
"logs",
"tmp",
"vm",
"media",
"docs",
"lib",
"node_modules",
"archive",
];
const EXTS: &[&str] = &[
".txt", ".log", ".jpg", ".png", ".mp4", ".iso", ".qcow2", ".go", ".c", ".pdf", ".zip",
".tar.gz", ".db", ".so", "", ".json", ".mkv", ".flac",
];
const UNI: &[&str] = &[
"日本語",
"Ünïcödé",
"emoji 🎉",
"Ελληνικά",
"кириллица",
"space name",
"quote'name",
"dash-name",
];
const BOOLS: &[&str] = &["sparse", "unicode", "hostile"];
/// SplitMix64: tiny, fast and good enough for synthetic data.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
fn n(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
fn f64(&mut self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
fn pick<'a>(&mut self, s: &[&'a str]) -> &'a str {
s[self.n(s.len())]
}
}
fn fatal(msg: impl std::fmt::Display) -> ! {
eprintln!("treegen: {msg}");
exit(1);
}
fn usage() -> ! {
eprintln!("usage: treegen --out DIR [--directories N] [--files N] [--max-depth N] [--sparse] [--unicode] [--hostile] [--max-size N] [--seed N] [--hotspots N]");
exit(2);
}
fn name(r: &mut Rng, i: usize, unicode: bool, hostile: bool) -> String {
if hostile && r.n(50) == 0 {
format!("evil\x1b[31m{i}\x1b]0;pwned\x07")
} else if unicode && r.n(5) == 0 {
format!("{}-{i}", r.pick(UNI))
} else {
format!("{}-{i}", r.pick(WORDS))
}
}
fn main() {
let mut out = None;
let (mut dirs, mut files, mut depth, mut max_size, mut seed, mut hotspots) =
(1000usize, 10000usize, 8usize, 1usize << 20, 1u64, 3usize);
let (mut sparse, mut unicode, mut hostile) = (false, false, false);
let mut args = std::env::args().skip(1);
while let Some(a) = args.next() {
let Some(flag) = a.strip_prefix("--").or_else(|| a.strip_prefix('-')) else {
usage()
};
let (key, inline) = match flag.split_once('=') {
Some((k, v)) => (k, Some(v.to_string())),
None => (flag, None),
};
if BOOLS.contains(&key) {
let on = match inline.as_deref() {
None | Some("true" | "1") => true,
Some("false" | "0") => false,
Some(v) => fatal(format!("invalid value {v:?} for -{key}")),
};
*match key {
"sparse" => &mut sparse,
"unicode" => &mut unicode,
_ => &mut hostile,
} = on;
continue;
}
let v = inline
.or_else(|| args.next())
.unwrap_or_else(|| fatal(format!("flag needs an argument: -{key}")));
let num = || {
v.parse::<u64>()
.unwrap_or_else(|_| fatal(format!("invalid value {v:?} for -{key}")))
};
match key {
"out" => out = Some(PathBuf::from(&v)),
"directories" => dirs = num() as usize,
"files" => files = num() as usize,
"max-depth" => depth = num() as usize,
"max-size" => max_size = num() as usize,
"seed" => seed = num(),
"hotspots" => hotspots = num() as usize,
_ => usage(),
}
}
let Some(out) = out else { usage() };
hostile &= cfg!(unix);
fs::create_dir_all(&out).unwrap_or_else(|e| fatal(e));
if fs::read_dir(&out).is_ok_and(|mut d| d.next().is_some()) {
fatal(format!("{} is not empty", out.display()));
}
let mut r = Rng(seed);
let start = Instant::now();
// Pick a depth first, then a directory at that depth, so the tree is broad
// near the top instead of one giant preferential-attachment chain.
let mut all = vec![(out.clone(), 0usize)];
let mut by_depth = vec![vec![0usize]];
while all.len() < dirs + 1 {
let lv = &by_depth[r.n(by_depth.len())];
let (parent, d) = all[lv[r.n(lv.len())]].clone();
if d >= depth {
continue;
}
let p = parent.join(name(&mut r, all.len(), unicode, hostile));
if fs::create_dir(&p).is_err() {
continue; // name collision; try again
}
all.push((p, d + 1));
if d + 1 == by_depth.len() {
by_depth.push(Vec::new());
}
by_depth[d + 1].push(all.len() - 1);
}
let hot: Vec<usize> = (0..hotspots).map(|_| r.n(all.len())).collect();
// Incompressible, so allocation is real everywhere.
let buf: Vec<u8> = (0..max_size).map(|_| r.next() as u8).collect();
let (mut bytes, mut created) = (0u64, 0usize);
for i in 0..files {
let d = if !hot.is_empty() && r.n(3) == 0 {
&all[hot[r.n(hot.len())]].0
} else {
&all[r.n(all.len())].0
};
let n = name(&mut r, i, unicode, hostile);
let p = d.join(n + r.pick(EXTS));
// Log-uniform sizes: many tiny files, a few large ones.
let mut size = (r.f64() * (max_size as f64).ln()).exp() as usize;
if r.n(20) == 0 {
size = 0;
}
let Ok(mut f) = OpenOptions::new().write(true).create_new(true).open(&p) else {
continue;
};
if sparse && r.n(200) == 0 {
// Sparse: a large logical size with almost nothing allocated.
f.write_all(b"x")
.and_then(|_| f.set_len((1 + r.n(8) as u64) << 30))
.unwrap_or_else(|e| fatal(e));
} else if size > 0 {
f.write_all(&buf[..size]).unwrap_or_else(|e| fatal(e));
bytes += size as u64;
}
created += 1;
if i % 100_000 == 0 && i > 0 {
eprintln!("{i} files…");
}
}
eprintln!(
"created {} directories, {created} files ({bytes} logical bytes) in {:.2?}",
all.len() - 1,
start.elapsed()
);
}