Skip to main content

tickwise_cli/
inspect.rs

1//! The inspect command: metadata and statistics for a recording.
2
3use std::collections::BTreeMap;
4use std::io::BufReader;
5use std::path::Path;
6use tickwise::format::{Chunk, FormatError, RecReader, SnapshotPolicy, kind};
7
8/// Rendered inspection output plus an integrity verdict.
9pub struct Report {
10    /// Human-readable report text.
11    pub text: String,
12    /// True when the file failed an integrity or structure check.
13    pub corrupt: bool,
14}
15
16/// Inspects the recording at the given path.
17pub fn render<P: AsRef<Path>>(path: P) -> Result<Report, FormatError> {
18    let path = path.as_ref();
19    let file_size = std::fs::metadata(path)?.len();
20    let file = std::fs::File::open(path)?;
21    let mut reader = RecReader::open(BufReader::new(file))?;
22
23    let header = reader.header().clone();
24    let version = reader.version();
25    let tick_count = reader.tick_count();
26
27    let mut input_frames: u64 = 0;
28    let mut light_batches: u64 = 0;
29    let mut light_hashes: u64 = 0;
30    let mut full_hashes: u64 = 0;
31    let mut snapshot_ticks: Vec<u64> = Vec::new();
32    let mut markers: u64 = 0;
33    let mut dump_ticks: Vec<u64> = Vec::new();
34    let mut unknown_chunks: u64 = 0;
35    let mut stream_error: Option<FormatError> = None;
36
37    for item in reader.chunks()? {
38        match item {
39            Ok(Chunk::InputFrame { .. }) => input_frames += 1,
40            Ok(Chunk::LightHashBatch { hashes, .. }) => {
41                light_batches += 1;
42                light_hashes += hashes.len() as u64;
43            }
44            Ok(Chunk::FullHash { .. }) => full_hashes += 1,
45            Ok(Chunk::Snapshot { tick, .. }) => snapshot_ticks.push(tick),
46            Ok(Chunk::Marker { .. }) => markers += 1,
47            Ok(Chunk::StateDump { tick, .. }) => dump_ticks.push(tick),
48            Ok(Chunk::Unknown { .. }) => unknown_chunks += 1,
49            Err(err) => {
50                stream_error = Some(err);
51                break;
52            }
53        }
54    }
55
56    // Byte sizes come from the seek index. BTreeMap keeps the iteration
57    // order deterministic, per the project's own determinism rules.
58    let mut bytes_by_kind: BTreeMap<u16, u64> = BTreeMap::new();
59    let mut index_error: Option<FormatError> = None;
60    match reader.read_index() {
61        Ok(entries) => {
62            for entry in entries {
63                *bytes_by_kind.entry(entry.kind).or_insert(0) += u64::from(entry.len) + 6;
64            }
65        }
66        Err(err) => index_error = Some(err),
67    }
68
69    let checksum_line = match reader.verify_checksum() {
70        Ok(()) => "checksum ok".to_string(),
71        Err(FormatError::ChecksumMismatch { stored, computed }) => {
72            format!("CHECKSUM MISMATCH, stored {stored:016x}, computed {computed:016x}")
73        }
74        Err(err) => return Err(err),
75    };
76    let corrupt =
77        checksum_line.starts_with("CHECKSUM") || stream_error.is_some() || index_error.is_some();
78
79    let meta = &header.meta;
80    let config = &header.config;
81    let mut s = String::new();
82
83    s.push_str(&format!("{}\n", path.display()));
84    s.push_str(&format!("  format         version {version}\n"));
85    s.push_str(&format!("  game           {}\n", or_unset(&meta.game_id)));
86    s.push_str(&format!(
87        "  build          {}\n",
88        or_unset(&meta.build_hash)
89    ));
90    s.push_str(&format!("  platform       {}\n", or_unset(&meta.platform)));
91    s.push_str(&format!(
92        "  tick rate      {} ticks per second\n",
93        meta.tick_rate
94    ));
95    s.push_str(&format!("  rng seed       {:#018x}\n", meta.rng_seed));
96    s.push_str(&format!("  created at     unix {}\n", meta.created_at));
97    s.push_str(&match config.full_hash_interval {
98        0 => "  full hashes    disabled\n".to_string(),
99        n => format!("  full hashes    every {n} ticks\n"),
100    });
101    s.push_str(&match config.snapshot_policy {
102        SnapshotPolicy::Off => "  snapshots      off\n".to_string(),
103        SnapshotPolicy::Every(n) => format!("  snapshots      every {n} ticks\n"),
104    });
105    s.push_str(&format!("  hash algo      id {}\n", config.hash_algo_id));
106    s.push_str(&format!("  input format   id {}\n", config.input_format_id));
107    s.push('\n');
108    s.push_str(&format!("  ticks          {tick_count}\n"));
109    s.push_str(&format!("  file size      {}\n", human_bytes(file_size)));
110    s.push('\n');
111    s.push_str("  chunks\n");
112
113    let row = |label: &str, count: u64, bytes: Option<&u64>, note: &str| -> String {
114        let size = bytes.map_or("n/a".to_string(), |b| human_bytes(*b));
115        let mut line = format!("    {label:<22} {count:>8}   {size:>10}");
116        if !note.is_empty() {
117            line.push_str("   ");
118            line.push_str(note);
119        }
120        line.push('\n');
121        line
122    };
123
124    s.push_str(&row(
125        "input frames",
126        input_frames,
127        bytes_by_kind.get(&kind::INPUT_FRAME),
128        "repeat suppressed",
129    ));
130    s.push_str(&row(
131        "light hash batches",
132        light_batches,
133        bytes_by_kind.get(&kind::LIGHT_HASH_BATCH),
134        &format!("holding {light_hashes} hashes"),
135    ));
136    s.push_str(&row(
137        "full hashes",
138        full_hashes,
139        bytes_by_kind.get(&kind::FULL_HASH),
140        "",
141    ));
142    s.push_str(&row(
143        "snapshots",
144        snapshot_ticks.len() as u64,
145        bytes_by_kind.get(&kind::SNAPSHOT),
146        &snapshot_note(&snapshot_ticks),
147    ));
148    s.push_str(&row(
149        "markers",
150        markers,
151        bytes_by_kind.get(&kind::MARKER),
152        "",
153    ));
154    if !dump_ticks.is_empty() {
155        s.push_str(&row(
156            "state dumps",
157            dump_ticks.len() as u64,
158            bytes_by_kind.get(&kind::STATE_DUMP),
159            &snapshot_note(&dump_ticks),
160        ));
161    }
162    if unknown_chunks > 0 {
163        let unknown_bytes: u64 = bytes_by_kind
164            .iter()
165            .filter(|(k, _)| !is_known_kind(**k))
166            .map(|(_, b)| *b)
167            .sum();
168        s.push_str(&row(
169            "unknown kinds",
170            unknown_chunks,
171            Some(&unknown_bytes),
172            "skipped safely",
173        ));
174    }
175    s.push('\n');
176
177    if let Some(err) = &stream_error {
178        s.push_str(&format!("  warning        chunk stream error: {err}\n"));
179    }
180    if let Some(err) = &index_error {
181        s.push_str(&format!("  warning        index unreadable: {err}\n"));
182    }
183    s.push_str(&format!("  integrity      {checksum_line}\n"));
184    s.push_str(
185        "  next           record a second session, then find the first divergent tick:\n\
186         \x20                tickwise compare a.rec b.rec\n",
187    );
188
189    Ok(Report { text: s, corrupt })
190}
191
192fn is_known_kind(id: u16) -> bool {
193    matches!(
194        id,
195        kind::INPUT_FRAME
196            | kind::LIGHT_HASH_BATCH
197            | kind::FULL_HASH
198            | kind::SNAPSHOT
199            | kind::MARKER
200            | kind::STATE_DUMP
201    )
202}
203
204fn or_unset(value: &str) -> &str {
205    if value.is_empty() { "unset" } else { value }
206}
207
208fn snapshot_note(ticks: &[u64]) -> String {
209    match ticks {
210        [] => String::new(),
211        few if few.len() <= 8 => {
212            let list: Vec<String> = few.iter().map(u64::to_string).collect();
213            format!("at ticks {}", list.join(", "))
214        }
215        many => format!(
216            "first at tick {}, last at tick {}",
217            many[0],
218            many[many.len() - 1]
219        ),
220    }
221}
222
223fn human_bytes(bytes: u64) -> String {
224    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
225    let mut value = bytes as f64;
226    let mut unit = 0;
227    while value >= 1024.0 && unit < UNITS.len() - 1 {
228        value /= 1024.0;
229        unit += 1;
230    }
231    if unit == 0 {
232        format!("{bytes} B")
233    } else {
234        format!("{value:.1} {}", UNITS[unit])
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn human_bytes_picks_sane_units() {
244        assert_eq!(human_bytes(0), "0 B");
245        assert_eq!(human_bytes(512), "512 B");
246        assert_eq!(human_bytes(2048), "2.0 KiB");
247        assert_eq!(human_bytes(5 * 1024 * 1024), "5.0 MiB");
248    }
249
250    #[test]
251    fn snapshot_notes_stay_short() {
252        assert_eq!(snapshot_note(&[]), "");
253        assert_eq!(snapshot_note(&[0, 100]), "at ticks 0, 100");
254        let many: Vec<u64> = (0..20).map(|i| i * 100).collect();
255        assert_eq!(snapshot_note(&many), "first at tick 0, last at tick 1900");
256    }
257}