Describe behaviour directly instead of by reference to the Go version

- platform::mode documents the on-disk mode-bit layout (bits 31..19 =
  d a l T L D p S u g c t ?, low 9 bits = permissions).
- The snapshot module documents the LEB128 / zig-zag varints and the RFC
  3339 JSON timestamps, and states that snapshots written by mapsize 1.x
  load unchanged.
- Renames: GoTime -> JsonTime, go_duration -> fmt_duration, fixture
  testdata/go.msz -> testdata/v1.2.0.msz (a snapshot written by mapsize
  1.2.0), loads_go_snapshot -> loads_1_2_snapshot.
- --log / --log-level help says they are accepted and ignored.
This commit is contained in:
Andy Dixon
2026-10-08 20:08:34 +01:00
parent fcac50bae6
commit 9179281a94
19 changed files with 113 additions and 92 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
//! In-process benchmarks mirroring the Go `go test -bench` suite
//! (internal/tui/bench_test.go, internal/treemap/treemap_test.go):
//! In-process benchmarks of the TUI model (render, navigation, filtering)
//! and the treemap layout:
//!
//! cargo run --release --example bench
//!
+1 -1
View File
@@ -100,7 +100,7 @@ fn start_source(
Ok((Some(s), t))
}
/// Cancels on SIGINT for report mode, like Go's signal.NotifyContext.
/// A token cancelled by SIGINT (Ctrl+C), for report mode.
fn interrupt_cancel() -> Cancel {
static CANCEL: std::sync::OnceLock<Cancel> = std::sync::OnceLock::new();
extern "C" fn on_sigint(_: libc::c_int) {
+2 -1
View File
@@ -1,4 +1,5 @@
//! A clonable cancellation token, the equivalent of a Go context's Done.
//! A clonable cancellation token: a flag to poll plus a channel that is
//! closed on cancellation, so it can also be waited on in a select.
use crossbeam_channel::{Receiver, Sender};
use std::sync::atomic::{AtomicBool, Ordering};
+5 -1
View File
@@ -213,7 +213,11 @@ pub fn summary(w: &mut dyn Write, t: &Tree) -> io::Result<()> {
textutil::size(r.tot_alloc),
r.tot_alloc
)?;
writeln!(w, "Elapsed {}", textutil::go_duration(st.elapsed()))?;
writeln!(
w,
"Elapsed {}",
textutil::fmt_duration(st.elapsed())
)?;
for (k, &c) in st.err_counts.iter().enumerate() {
if c > 0 {
writeln!(
+1 -1
View File
@@ -24,7 +24,7 @@ fn tok(kind: TokKind, val: &str, pos: usize) -> Token {
}
}
/// ASCII white space as Go's unicode.IsSpace sees it.
/// ASCII white space: space, \t, \n, \v, \f, \r.
fn is_space(c: u8) -> bool {
matches!(c, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r')
}
+1 -1
View File
@@ -150,7 +150,7 @@ mod tests {
);
}
assert!(names(&tr, "[").is_empty()); // malformed bare glob matches nothing
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // Go's lexer hangs on byte 0xA0
assert!(names(&tr, "voilà\u{a0}x").is_empty()); // regression: a lexer once hung on byte 0xA0 (NBSP)
}
#[test]
+7 -7
View File
@@ -309,7 +309,7 @@ fn has_glob(s: &str) -> bool {
pub(super) fn name_match(v: &str) -> Expr {
let lv = v.to_lowercase();
if has_glob(v) {
// Go's filepath.Match never matches a malformed pattern.
// A malformed glob never matches.
return if glob_ok(lv.as_bytes()) {
Expr::Glob(Field::Name, lv)
} else {
@@ -319,8 +319,9 @@ pub(super) fn name_match(v: &str) -> Expr {
Expr::Contains(Field::Name, lv)
}
/// Reports whether pattern is well formed by Go's filepath.Match rules
/// (fnmatch, which does the matching, accepts anything).
/// Reports whether pattern is a well-formed glob (see
/// `platform::glob_match`): every `[` class is closed and non-empty, ranges
/// have both ends, and no escape is dangling.
fn glob_ok(p: &[u8]) -> bool {
// One possibly escaped character of a range; None if malformed.
let esc = |mut i: usize| -> Option<usize> {
@@ -461,13 +462,12 @@ pub fn parse_age(s: &str) -> Result<Duration, String> {
))
}
};
// Saturates where Go's time.Duration would overflow.
// Saturates beyond u64 nanoseconds (about 584 years).
Ok(Duration::from_nanos((v * secs * 1e9) as u64))
}
/// Parses "2006-01-02", "2006-01-02T15:04", "2006-01-02 15:04" (local time)
/// or RFC 3339, as Go's time.ParseInLocation with time.Local would, into unix
/// nanoseconds.
/// or RFC 3339 (with its own offset) into unix nanoseconds.
fn parse_date(s: &str) -> Option<i64> {
let b = s.as_bytes();
let fixed = |i: usize, n: usize| -> Option<i64> {
@@ -492,7 +492,7 @@ fn parse_date(s: &str) -> Option<i64> {
if sep != b'T' && sep != b' ' {
return None;
}
// Go's "15" hour accepts one or two digits.
// The hour may have one or two digits.
let mut i = 11;
let hl = if fixed(i, 2).is_some() { 2 } else { 1 };
let h = fixed(i, hl)?;
+1 -1
View File
@@ -80,7 +80,7 @@ pub struct Node {
pub files: u32, // recursive counts beneath (dirs excludes self)
pub dirs: u32,
pub errors: u32, // recursive error count beneath and including self
pub mode: u32, // Go fs.FileMode bit layout (see platform::mode)
pub mode: u32, // portable mode bits (see platform::mode)
pub uid: u32,
pub gid: u32,
pub nlink: u32,
+1 -1
View File
@@ -1,4 +1,4 @@
//! mapsize: interactive terminal disk usage analyser (Rust port).
//! mapsize: interactive terminal disk usage analyser.
pub mod app;
pub mod brand;
+4 -4
View File
@@ -5,7 +5,7 @@ use mapsize::inventory::SizeMode;
use mapsize::{brand, config, scan, snapshot, textutil};
use std::path::Path;
/// Flags taking a value (Go's flag package: `-f v`, `-f=v`, `--f v`, `--f=v`).
/// Flags taking a value, written `-f v`, `-f=v`, `--f v` or `--f=v`.
const VALUE_FLAGS: &[&str] = &[
"workers",
"follow-symlinks",
@@ -71,8 +71,8 @@ Flags:
--theme NAME colour theme: default, dark, high-contrast, mono
--color MODE colour support: auto, truecolor, 256, 16, none
--no-mouse disable mouse support
--log FILE write a debug log to FILE
--log-level LEVEL log level: error, warn, info, debug, trace
--log FILE accepted for compatibility; ignored
--log-level LEVEL accepted for compatibility (error, warn, info, debug, trace); ignored
--version print version and exit
"
);
@@ -171,7 +171,7 @@ fn run(args: Vec<String>) -> i32 {
return fail(format!("invalid --log-level {l:?}"));
}
}
// ponytail: --log is accepted for CLI compatibility; the Rust port has no debug logging yet.
// ponytail: --log and --log-level are accepted for compatibility and ignored; there is no debug logging.
let (depth, top, largest) = match (int("depth", -1), int("top", 0), int("largest-files", 0)) {
(Ok(d), Ok(t), Ok(l)) => (d, t.max(0) as usize, l.max(0) as usize),
+2 -1
View File
@@ -651,7 +651,8 @@ impl Controller {
}
}
/// The OS error text without Rust's " (os error N)" suffix, matching Go.
/// The OS error text, lower-cased and without the " (os error N)" suffix,
/// e.g. "permission denied".
fn err_msg(e: &io::Error) -> String {
let s = e.to_string();
match s.rfind(" (os error ") {
+70 -56
View File
@@ -21,10 +21,22 @@
//! sha256 32 bytes over the uncompressed body
//! ```
//!
//! Varints are Go's encoding/binary (zig-zag for signed). Nodes are written
//! in ID order; a parent always precedes its children, so a reader can
//! rebuild the tree in one pass and malformed input cannot create cycles.
//! Aggregates are recomputed on load, never trusted.
//! A uvarint is unsigned LEB128: 7 bits per byte, least significant group
//! first, the high bit set on every byte but the last; at most 10 bytes for
//! 64 bits. A varint (signed) is zig-zag encoded first, (v << 1) ^ (v >> 63),
//! so small magnitudes of either sign stay short: 0, -1, 1, -2 → 0, 1, 2, 3.
//!
//! In the meta JSON, timestamps are RFC 3339 strings with nanoseconds
//! (trailing zeros trimmed) and the writer's UTC offset, or "Z" for UTC,
//! e.g. "2026-10-08T10:40:00.123456789+01:00". An unset time is
//! "0001-01-01T00:00:00Z"; null is read as unset.
//!
//! Compatibility: snapshots written by mapsize 1.x load unchanged, and the
//! mode bits (see `platform::mode`) mean the same on every OS.
//!
//! Nodes are written in ID order; a parent always precedes its children, so
//! a reader can rebuild the tree in one pass and malformed input cannot
//! create cycles. Aggregates are recomputed on load, never trusted.
mod compare;
@@ -65,9 +77,9 @@ const FLAG_GZIPPED: u16 = 1;
#[serde(default)]
struct Meta {
root: String,
created: GoTime,
start: GoTime,
end: GoTime,
created: JsonTime,
start: JsonTime,
end: JsonTime,
complete: bool,
cancelled: bool,
files: i64,
@@ -131,7 +143,7 @@ impl<W: Write> Writer<W> {
self.bytes(&b[..=i])
}
fn varint(&mut self, v: i64) -> io::Result<()> {
self.uvarint(((v << 1) ^ (v >> 63)) as u64) // zig-zag, as encoding/binary.PutVarint
self.uvarint(((v << 1) ^ (v >> 63)) as u64) // zig-zag
}
fn str(&mut self, s: &[u8]) -> io::Result<()> {
self.uvarint(s.len() as u64)?;
@@ -154,11 +166,11 @@ pub fn save(out: &mut dyn Write, t: &Tree) -> io::Result<()> {
let st = &t.stats;
let m = Meta {
root: String::from_utf8_lossy(&st.root).into_owned(),
created: GoTime::from_system(SystemTime::now()),
start: GoTime::from_system(st.start).local(),
created: JsonTime::from_system(SystemTime::now()),
start: JsonTime::from_system(st.start).local(),
end: st
.end
.map(|e| GoTime::from_system(e).local())
.map(|e| JsonTime::from_system(e).local())
.unwrap_or_default(),
complete: st.complete,
cancelled: st.cancelled,
@@ -233,7 +245,8 @@ pub fn save_file(path: &Path, t: &Tree) -> io::Result<()> {
res
}
/// Creates a new 0600 file named .mapsize-*.tmp in dir (like os.CreateTemp).
/// Creates a new private (0600 on Unix) file named .mapsize-<random>.tmp in
/// dir, retrying on name collisions.
fn create_temp(dir: &Path) -> io::Result<(PathBuf, File)> {
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -303,7 +316,8 @@ impl Body {
Ok(self.buf[self.pos - 1])
}
/// Fills out completely (errors like io.ReadFull).
/// Fills out completely: "EOF" if the stream ends before the first byte,
/// "unexpected EOF" if it ends part way.
fn read_full(&mut self, out: &mut [u8]) -> Result<(), String> {
let mut done = 0;
while done < out.len() {
@@ -332,7 +346,7 @@ impl Body {
fn varint(&mut self, what: &str) -> Result<i64, String> {
let ux = read_uvarint(self).map_err(|e| format!("snapshot: bad {what}: {e}"))?;
let v = (ux >> 1) as i64; // zig-zag, as encoding/binary.PutVarint
let v = (ux >> 1) as i64; // undo zig-zag
Ok(if ux & 1 != 0 { !v } else { v })
}
@@ -349,7 +363,7 @@ impl Body {
}
}
/// encoding/binary.ReadUvarint over the body.
/// Reads a uvarint (LEB128) from the body; more than 64 bits is an error.
fn read_uvarint(b: &mut Body) -> Result<u64, String> {
let (mut x, mut s) = (0u64, 0u32);
for i in 0..10 {
@@ -611,32 +625,32 @@ fn valid_name(s: &[u8]) -> bool {
!s.is_empty() && s != b"." && s != b".." && !s.iter().any(|&c| c == b'/' || c == 0)
}
/// A Go time.Time as JSON carries it: unix seconds plus nanoseconds, and
/// the UTC offset it is written with. The default is Go's zero time
/// (0001-01-01T00:00:00Z).
/// A timestamp as the meta JSON carries it (see the module docs): unix
/// seconds plus nanoseconds, and the UTC offset it is written with. The
/// default is the unset time, 0001-01-01T00:00:00Z.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct GoTime {
struct JsonTime {
secs: i64,
nanos: u32,
off: i32, // seconds east of UTC; presentation only
}
const GO_ZERO_SECS: i64 = -62_135_596_800;
const ZERO_SECS: i64 = -62_135_596_800;
impl Default for GoTime {
impl Default for JsonTime {
fn default() -> Self {
GoTime {
secs: GO_ZERO_SECS,
JsonTime {
secs: ZERO_SECS,
nanos: 0,
off: 0,
}
}
}
impl GoTime {
fn from_system(t: SystemTime) -> GoTime {
impl JsonTime {
fn from_system(t: SystemTime) -> JsonTime {
match t.duration_since(UNIX_EPOCH) {
Ok(d) => GoTime {
Ok(d) => JsonTime {
secs: d.as_secs() as i64,
nanos: d.subsec_nanos(),
off: 0,
@@ -649,7 +663,7 @@ impl GoTime {
} else {
(-s - 1, 1_000_000_000 - n)
};
GoTime {
JsonTime {
secs,
nanos,
off: 0,
@@ -658,14 +672,14 @@ impl GoTime {
}
}
/// The same instant in the local time zone (as Go's time.Now()).
fn local(self) -> GoTime {
/// The same instant, presented in the local time zone.
fn local(self) -> JsonTime {
let off = crate::platform::local_time(self.secs).off;
GoTime { off, ..self }
JsonTime { off, ..self }
}
fn is_zero(self) -> bool {
(self.secs, self.nanos) == (GO_ZERO_SECS, 0)
(self.secs, self.nanos) == (ZERO_SECS, 0)
}
fn to_system(self) -> SystemTime {
@@ -677,7 +691,7 @@ impl GoTime {
}
}
/// RFC 3339 with trimmed nanoseconds, like Go's MarshalJSON.
/// RFC 3339 with nanoseconds (trailing zeros trimmed) and "Z" for UTC.
fn format(self) -> String {
let secs = self.secs + self.off as i64;
let (y, mo, d) = civil_from_days(secs.div_euclid(86400));
@@ -704,7 +718,7 @@ impl GoTime {
}
/// Parses RFC 3339 ("2026-10-08T10:40:00.123456789+01:00").
fn parse(s: &str) -> Option<GoTime> {
fn parse(s: &str) -> Option<JsonTime> {
let b = s.as_bytes();
let num = |r: std::ops::Range<usize>| -> Option<i64> {
let p = b.get(r)?;
@@ -762,7 +776,7 @@ impl GoTime {
_ => return None,
};
let secs = days_from_civil(y, mo, d) * 86400 + h * 3600 + mi * 60 + se - off;
Some(GoTime {
Some(JsonTime {
secs,
nanos,
off: off as i32,
@@ -779,18 +793,18 @@ impl GoTime {
}
}
impl Serialize for GoTime {
impl Serialize for JsonTime {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.format())
}
}
impl<'de> Deserialize<'de> for GoTime {
impl<'de> Deserialize<'de> for JsonTime {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
// null leaves the zero time, as in Go.
// null leaves the time unset.
match Option::<String>::deserialize(d)? {
None => Ok(GoTime::default()),
Some(s) => GoTime::parse(&s)
None => Ok(JsonTime::default()),
Some(s) => JsonTime::parse(&s)
.ok_or_else(|| serde::de::Error::custom(format!("parsing time {s:?}"))),
}
}
@@ -914,7 +928,7 @@ mod tests {
}
#[test]
fn read_uvarint_matches_go() {
fn varint_encoding() {
for v in [0, 1, 127, 128, 300, 1 << 35, u64::MAX] {
assert_eq!(read_uvarint(&mut body(&put_uvarint(v))), Ok(v));
}
@@ -923,7 +937,7 @@ mod tests {
read_uvarint(&mut body(&[0xff; 11])).is_err(),
"overflow not detected"
);
// Zig-zag as Go's PutVarint.
// Zig-zag.
for (v, enc) in [(0i64, 0u64), (-1, 1), (1, 2), (-2, 3), (i64::MIN, u64::MAX)] {
assert_eq!(((v << 1) ^ (v >> 63)) as u64, enc);
assert_eq!(body(&put_uvarint(enc)).varint("v"), Ok(v));
@@ -931,16 +945,16 @@ mod tests {
}
#[test]
fn go_time_json() {
let t = GoTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap();
fn json_time() {
let t = JsonTime::parse("2026-10-08T10:40:00.123456789+01:00").unwrap();
assert_eq!((t.secs, t.nanos, t.off), (1_791_452_400, 123_456_789, 3600));
assert_eq!(t.format(), "2026-10-08T10:40:00.123456789+01:00");
assert_eq!(
GoTime { off: 0, ..t }.format(),
JsonTime { off: 0, ..t }.format(),
"2026-10-08T09:40:00.123456789Z"
);
assert_eq!(
GoTime {
JsonTime {
off: -5 * 3600 - 1800,
..t
}
@@ -948,17 +962,17 @@ mod tests {
"2026-10-08T04:10:00.123456789-05:30"
);
assert_eq!(
GoTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(),
JsonTime::parse("2026-10-08T09:40:00.1Z").unwrap().format(),
"2026-10-08T09:40:00.1Z"
);
assert_eq!(
GoTime::parse("0001-01-01T00:00:00Z"),
Some(GoTime::default())
JsonTime::parse("0001-01-01T00:00:00Z"),
Some(JsonTime::default())
);
assert_eq!(GoTime::default().format(), "0001-01-01T00:00:00Z");
assert_eq!(JsonTime::default().format(), "0001-01-01T00:00:00Z");
assert_eq!(
GoTime::from_system(GoTime::default().to_system()),
GoTime::default()
JsonTime::from_system(JsonTime::default().to_system()),
JsonTime::default()
);
for bad in [
"",
@@ -967,17 +981,17 @@ mod tests {
"2026-10-08T00:00:00",
"2026-10-08T00:00:00.Z",
] {
assert_eq!(GoTime::parse(bad), None, "{bad}");
assert_eq!(JsonTime::parse(bad), None, "{bad}");
}
}
/// A tiny tree saved by the Go binary
/// A snapshot written by mapsize 1.2.0 of a tiny tree
/// (tree/{a/x.iso 10000 B, a/b/note.txt, c/y.log 3000 B, c/link -> ../a}).
#[test]
fn loads_go_snapshot() {
fn loads_1_2_snapshot() {
let t = load_file(Path::new(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/snapshot/testdata/go.msz"
"/src/snapshot/testdata/v1.2.0.msz"
)))
.unwrap();
let r = t.node(0);
@@ -997,7 +1011,7 @@ mod tests {
assert_eq!(t.node(iso).size, 10000);
assert_eq!(t.ext_name(t.node(iso)), "iso");
assert!(t.path_string(iso).ends_with("/tree/a/x.iso"));
// And it survives a Rust round trip unchanged.
// And it survives a save/load round trip unchanged.
let again = load(&saved(&t)[..]).unwrap();
assert_eq!(
(again.node(0).tot_size, again.len(), again.stats.start),
+6 -6
View File
@@ -357,9 +357,9 @@ pub fn rfc3339_utc(unix_nanos: i64) -> String {
)
}
/// Formats a duration like Go's time.Duration.String after rounding to
/// milliseconds, e.g. "1.234s", "250ms", "2m3.5s".
pub fn go_duration(d: Duration) -> String {
/// Formats a duration rounded to milliseconds: "0s", "250ms", "1.234s",
/// "2m3.5s", "1h0m5s" (trailing zeros of the seconds trimmed).
pub fn fmt_duration(d: Duration) -> String {
let ms = (d.as_nanos() + 500_000) / 1_000_000;
if ms == 0 {
return "0s".into();
@@ -393,8 +393,8 @@ mod time_tests {
rfc3339_utc(1_791_456_000 * 1_000_000_000),
"2026-10-08T10:40:00Z"
);
assert_eq!(go_duration(Duration::from_micros(1_234_400)), "1.234s");
assert_eq!(go_duration(Duration::from_millis(250)), "250ms");
assert_eq!(go_duration(Duration::from_millis(123_500)), "2m3.5s");
assert_eq!(fmt_duration(Duration::from_micros(1_234_400)), "1.234s");
assert_eq!(fmt_duration(Duration::from_millis(250)), "250ms");
assert_eq!(fmt_duration(Duration::from_millis(123_500)), "2m3.5s");
}
}
+1 -1
View File
@@ -326,7 +326,7 @@ pub(crate) mod tests {
}
}
/// The Go fuzz target's property, run over fixed seeds.
/// Squarify invariants over random partitions, from fixed seeds.
#[test]
fn squarify_random_partitions() {
let mut rng = Rng::new(1);
+1 -1
View File
@@ -1,5 +1,5 @@
//! Benchmark hooks: the model driven with no terminal, rendering in
//! true colour, as the Go benchmarks do.
//! true colour.
use super::model::{Key, Model, Msg, Options, Profile};
use crate::cancel::Cancel;
+2 -2
View File
@@ -58,7 +58,7 @@ pub(crate) enum Profile {
Ascii,
}
/// A key press. name follows Bubble Tea's keystroke names ("a", "G",
/// A key press. name is the keystroke name ("a", "G",
/// "ctrl+c", "shift+tab", "space", "pgdown", …); text is the inserted
/// text for printable keys.
#[derive(Clone, Debug, Default)]
@@ -69,7 +69,7 @@ pub(crate) struct Key {
}
impl Key {
/// A key from its keystroke name, as the Go tests build them: single
/// A key from its keystroke name, as tests build them: single
/// characters insert themselves, "space" inserts " ".
pub fn from_name(s: &str) -> Key {
let text = match s {
+2 -2
View File
@@ -162,7 +162,7 @@ fn enter_terminal(mouse: bool) -> io::Result<()> {
Ok(())
}
/// Bubble Tea keystroke name for a key event.
/// The keystroke name (see `Key`) for a key event.
fn convert_key(ev: KeyEvent) -> Option<Key> {
if ev.kind == KeyEventKind::Release {
return None;
@@ -251,7 +251,7 @@ fn convert(ev: Event) -> Option<Msg> {
_ => None,
}
}
// Pastes are ignored, as in the Go version.
// Pastes are ignored.
_ => None,
}
}
+4 -3
View File
@@ -1,4 +1,5 @@
//! Headless model tests (ports of tui_test.go, nest_test.go, live_test.go).
//! Headless model tests: rendering and navigation, nested directories,
//! and a model driven by a live scan.
use super::bench::opts_for;
use super::canvas::rgb;
@@ -536,7 +537,7 @@ fn copy_refuses_control_characters() {
);
}
// ---- nest_test.go -----------------------------------------------------------
// ---- Nested directories -----------------------------------------------------
fn root_tree() -> Tree {
let mut t = Tree::new(b"/", Kind::Dir);
@@ -611,7 +612,7 @@ fn reveal_headless_zooms_map() {
);
}
// ---- live_test.go -----------------------------------------------------------
// ---- Live scan --------------------------------------------------------------
/// Renders, navigates, filters and resizes while the scanner mutates the
/// inventory, verifying the locking discipline.