Skip to main content

ras_commander_fast_map/
sink.rs

1//! Deterministic streaming directory sink with failure-safe unit commits.
2//!
3//! Layout: `<out>/units/<profile>/<aoi>/<product>.bin` (row-major over the unit window, grid row
4//! order) plus `unit.json`. Tiles are written with positioned writes, so file bytes do not depend
5//! on completion order; per-tile records are sorted canonically before `unit.json` is written.
6//! Only the tiles in flight are held in memory; no catalog-wide raster or JSON is materialized.
7//!
8//! Commit protocol (a `.bin` is trustworthy only when `unit.json` says `complete`):
9//! 1. `begin_unit` atomically writes `unit.json` with `status: in_progress` *before* any data,
10//!    then creates `<product>.bin.partial` files.
11//! 2. `end_unit` on success: `fsync` every partial (errors propagate), rename to `.bin`, sync the
12//!    directory, then atomically replace `unit.json` with the `complete` document.
13//! 3. On any failure (including commit failures and `abort_all` after a session error), the
14//!    unit's data files are removed and `unit.json` is replaced with `status: failed`.
15//! 4. A crash that prevents cleanup leaves `in_progress` plus `.partial` names: explicit state.
16//!
17//! Every JSON document is written to `<name>.partial`, fsynced and renamed (`write_json`).
18use crate::MapError;
19use crate::model::Product;
20use serde::Serialize;
21use serde::ser::{SerializeMap, SerializeSeq};
22use std::collections::BTreeMap;
23use std::fs::{self, File, OpenOptions};
24use std::io::Write;
25use std::path::{Path, PathBuf};
26
27/// Suffix of files that are not (yet) committed.
28pub const PARTIAL_SUFFIX: &str = ".partial";
29
30/// Pixel rectangle on a profile grid: col, row, width, height.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, PartialOrd, Ord)]
32pub struct Rect {
33    pub col: usize,
34    pub row: usize,
35    pub width: usize,
36    pub height: usize,
37}
38
39impl Rect {
40    pub fn intersect(&self, o: &Rect) -> Option<Rect> {
41        let c0 = self.col.max(o.col);
42        let r0 = self.row.max(o.row);
43        let c1 = (self.col + self.width).min(o.col + o.width);
44        let r1 = (self.row + self.height).min(o.row + o.height);
45        (c0 < c1 && r0 < r1).then(|| Rect {
46            col: c0,
47            row: r0,
48            width: c1 - c0,
49            height: r1 - r0,
50        })
51    }
52    pub fn pixels(&self) -> usize {
53        self.width * self.height
54    }
55}
56
57/// One evaluated tile: buffers cover `rect` row-major. NaN / -1 mark nodata.
58pub struct TileOut {
59    pub profile: usize,
60    pub tile: (usize, usize),
61    pub rect: Rect,
62    pub raw_wse: Vec<f64>,
63    pub wse: Vec<f64>,
64    pub depth: Vec<f64>,
65    pub ground: Vec<f64>,
66    pub simplex_id: Vec<i64>,
67}
68
69/// Compact retained per-tile record (fixed size; hashes indexed like `DirSink::products`).
70#[derive(Clone)]
71pub struct TileRecord {
72    tile_row: usize,
73    tile_col: usize,
74    rect: Rect,
75    fnv1a64: [u64; 5],
76    raw_wse_valid: u64,
77    wet: u64,
78}
79
80/// Bytes retained per tile record until its unit closes (exact element size).
81pub const TILE_RECORD_BYTES: u64 = std::mem::size_of::<TileRecord>() as u64;
82
83/// The r1 serialized shape of a tile record; built one at a time while streaming `unit.json`
84/// so the document bytes equal r1's for complete units.
85#[derive(Serialize)]
86struct TileRecordJson {
87    tile_row: usize,
88    tile_col: usize,
89    rect: Rect,
90    fnv1a64: BTreeMap<&'static str, String>,
91    raw_wse_valid: u64,
92    wet: u64,
93}
94
95struct UnitState {
96    dir: PathBuf,
97    window: Rect,
98    header: serde_json::Value,
99    files: Vec<(Product, File)>,
100    tiles: Vec<TileRecord>,
101    bytes: u64,
102}
103
104pub struct DirSink {
105    root: PathBuf,
106    products: Vec<Product>,
107    /// (profile slot, aoi index) -> state, for profiles currently in flight.
108    units: BTreeMap<(usize, usize), UnitState>,
109    pub bytes_written: u64,
110    pub tiles_written: u64,
111    pub unit_files_written: u64,
112    /// Directory fsyncs the filesystem rejected as unsupported (data files are still synced).
113    pub dir_sync_unsupported: u64,
114}
115
116pub fn fnv1a64(bytes: &[u8]) -> u64 {
117    let mut h: u64 = 0xcbf29ce484222325;
118    for b in bytes {
119        h ^= *b as u64;
120        h = h.wrapping_mul(0x100000001b3);
121    }
122    h
123}
124
125#[cfg(unix)]
126fn write_at(f: &File, buf: &[u8], off: u64) -> std::io::Result<()> {
127    use std::os::unix::fs::FileExt;
128    f.write_all_at(buf, off)
129}
130
131#[cfg(windows)]
132fn write_at(f: &File, mut buf: &[u8], mut off: u64) -> std::io::Result<()> {
133    use std::os::windows::fs::FileExt;
134    while !buf.is_empty() {
135        let n = f.seek_write(buf, off)?;
136        if n == 0 {
137            return Err(std::io::Error::other("short positioned write"));
138        }
139        buf = &buf[n..];
140        off += n as u64;
141    }
142    Ok(())
143}
144
145fn io_at(path: &Path, e: std::io::Error) -> MapError {
146    MapError::Io(format!("{}: {e}", path.display()))
147}
148
149fn partial(path: &Path) -> PathBuf {
150    let mut s = path.as_os_str().to_os_string();
151    s.push(PARTIAL_SUFFIX);
152    PathBuf::from(s)
153}
154
155/// Syncs a directory so renames are durable. Returns Ok(false) when the filesystem does not
156/// support directory fsync (EINVAL/unsupported); other errors propagate. No-op on Windows.
157fn sync_dir(dir: &Path) -> Result<bool, MapError> {
158    #[cfg(unix)]
159    {
160        let r = File::open(dir).and_then(|d| d.sync_all());
161        match r {
162            Ok(()) => Ok(true),
163            Err(e)
164                if e.kind() == std::io::ErrorKind::InvalidInput
165                    || e.kind() == std::io::ErrorKind::Unsupported =>
166            {
167                Ok(false)
168            }
169            Err(e) => Err(io_at(dir, e)),
170        }
171    }
172    #[cfg(not(unix))]
173    {
174        let _ = dir;
175        Ok(false)
176    }
177}
178
179/// Writes `<path>.partial`, fsyncs it (errors propagate) and renames it over `path`.
180fn write_atomic(
181    path: &Path,
182    fill: impl FnOnce(&mut std::io::BufWriter<&File>) -> Result<(), MapError>,
183) -> Result<(), MapError> {
184    let tmp = partial(path);
185    let result = (|| {
186        let f = File::create(&tmp).map_err(|e| io_at(&tmp, e))?;
187        {
188            let mut w = std::io::BufWriter::new(&f);
189            fill(&mut w)?;
190            w.flush().map_err(|e| io_at(&tmp, e))?;
191        }
192        f.sync_all().map_err(|e| io_at(&tmp, e))?;
193        drop(f);
194        fs::rename(&tmp, path).map_err(|e| io_at(path, e))
195    })();
196    if result.is_err() {
197        let _ = fs::remove_file(&tmp);
198    }
199    result
200}
201
202/// Atomic pretty JSON write (temp file, fsync, rename).
203pub fn write_json(path: &Path, v: &serde_json::Value) -> Result<(), MapError> {
204    write_atomic(path, |w| {
205        serde_json::to_writer_pretty(w, v)
206            .map_err(|e| MapError::Io(format!("{}: {e}", path.display())))
207    })
208}
209
210/// Streams a unit document: `doc`'s entries in their own order, with the `tiles` placeholder
211/// replaced by the records (serialized one at a time). Equal bytes to `to_vec_pretty` of the
212/// fully materialized r1 document, without holding a JSON tree of every record.
213struct UnitDoc<'a> {
214    doc: &'a serde_json::Map<String, serde_json::Value>,
215    tiles: &'a [TileRecord],
216    names: &'a [&'static str],
217}
218
219struct TilesSeq<'a> {
220    tiles: &'a [TileRecord],
221    names: &'a [&'static str],
222}
223
224impl Serialize for TilesSeq<'_> {
225    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
226        let mut seq = s.serialize_seq(Some(self.tiles.len()))?;
227        for t in self.tiles {
228            let rec = TileRecordJson {
229                tile_row: t.tile_row,
230                tile_col: t.tile_col,
231                rect: t.rect,
232                fnv1a64: self
233                    .names
234                    .iter()
235                    .zip(t.fnv1a64)
236                    .map(|(n, h)| (*n, format!("{h:016x}")))
237                    .collect(),
238                raw_wse_valid: t.raw_wse_valid,
239                wet: t.wet,
240            };
241            // r1 serialized `to_value(records)`; per-record values reproduce it exactly.
242            let v = serde_json::to_value(&rec).map_err(serde::ser::Error::custom)?;
243            seq.serialize_element(&v)?;
244        }
245        seq.end()
246    }
247}
248
249impl Serialize for UnitDoc<'_> {
250    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
251        let mut m = s.serialize_map(Some(self.doc.len()))?;
252        for (k, v) in self.doc {
253            if k == "tiles" {
254                m.serialize_entry(
255                    k,
256                    &TilesSeq {
257                        tiles: self.tiles,
258                        names: self.names,
259                    },
260                )?;
261            } else {
262                m.serialize_entry(k, v)?;
263            }
264        }
265        m.end()
266    }
267}
268
269impl DirSink {
270    /// `out` must not exist; it is created (parent must exist).
271    pub fn create(out: &Path, products: &[Product]) -> Result<Self, MapError> {
272        fs::create_dir(out).map_err(|e| {
273            MapError::Io(format!(
274                "{}: {e} (output directory must be new)",
275                out.display()
276            ))
277        })?;
278        fs::create_dir(out.join("units")).map_err(|e| io_at(&out.join("units"), e))?;
279        let mut products = products.to_vec();
280        products.sort();
281        if products.len() > 5 {
282            return Err(MapError::Internal("more than five products".into()));
283        }
284        Ok(Self {
285            root: out.to_path_buf(),
286            products,
287            units: BTreeMap::new(),
288            bytes_written: 0,
289            tiles_written: 0,
290            unit_files_written: 0,
291            dir_sync_unsupported: 0,
292        })
293    }
294
295    pub fn unit_dir(&self, profile_id: &str, aoi_id: &str) -> PathBuf {
296        self.root.join("units").join(profile_id).join(aoi_id)
297    }
298
299    fn data_path(dir: &Path, p: Product) -> PathBuf {
300        dir.join(format!("{}.bin", p.name()))
301    }
302
303    /// Opens a unit; `window` None writes an `empty` unit immediately. `expected_tiles` (exact
304    /// count of tiles intersecting the window) reserves the record vector once.
305    pub fn begin_unit(
306        &mut self,
307        key: (usize, usize),
308        profile_id: &str,
309        aoi_id: &str,
310        window: Option<Rect>,
311        header: &serde_json::Value,
312        expected_tiles: usize,
313    ) -> Result<(), MapError> {
314        let dir = self.unit_dir(profile_id, aoi_id);
315        fs::create_dir_all(&dir).map_err(|e| io_at(&dir, e))?;
316        let Some(window) = window else {
317            let mut doc = header.clone();
318            doc["status"] = "empty_window".into();
319            return write_json(&dir.join("unit.json"), &doc);
320        };
321        // Explicit state before any data byte exists.
322        let mut marker = header.clone();
323        marker["status"] = "in_progress".into();
324        write_json(&dir.join("unit.json"), &marker)?;
325        let mut tiles = Vec::new();
326        tiles
327            .try_reserve_exact(expected_tiles)
328            .map_err(|e| MapError::Budget(format!("tile records for {}: {e}", dir.display())))?;
329        // Registered before files are created, so a failure below is cleaned up by `abort_all`.
330        self.units.insert(
331            key,
332            UnitState {
333                dir: dir.clone(),
334                window,
335                header: header.clone(),
336                files: Vec::new(),
337                tiles,
338                bytes: 0,
339            },
340        );
341        let u = self.units.get_mut(&key).expect("just inserted");
342        for p in &self.products {
343            let path = partial(&Self::data_path(&dir, *p));
344            let f = OpenOptions::new()
345                .write(true)
346                .create_new(true)
347                .open(&path)
348                .map_err(|e| io_at(&path, e))?;
349            u.files.push((*p, f));
350            let (_, f) = u.files.last().expect("just pushed");
351            f.set_len(window.pixels() as u64 * 8)
352                .map_err(|e| io_at(&path, e))?;
353        }
354        Ok(())
355    }
356
357    /// Writes the part of `t` inside every open unit of its profile.
358    pub fn write_tile(&mut self, t: &TileOut, aoi_count: usize) -> Result<(), MapError> {
359        let mut row_buf: Vec<u8> = Vec::with_capacity(t.rect.width * 8);
360        for aoi in 0..aoi_count {
361            let Some(u) = self.units.get_mut(&(t.profile, aoi)) else {
362                continue;
363            };
364            let Some(ix) = u.window.intersect(&t.rect) else {
365                continue;
366            };
367            let mut rec = TileRecord {
368                tile_row: t.tile.0,
369                tile_col: t.tile.1,
370                rect: ix,
371                fnv1a64: [0; 5],
372                raw_wse_valid: 0,
373                wet: 0,
374            };
375            for r in ix.row..ix.row + ix.height {
376                let src = (r - t.rect.row) * t.rect.width + (ix.col - t.rect.col);
377                for c in 0..ix.width {
378                    rec.raw_wse_valid +=
379                        (!t.raw_wse.is_empty() && !t.raw_wse[src + c].is_nan()) as u64;
380                    rec.wet += (!t.wse.is_empty() && !t.wse[src + c].is_nan()) as u64;
381                }
382            }
383            for (pi, (p, f)) in u.files.iter().enumerate() {
384                let mut hash: u64 = 0xcbf29ce484222325;
385                for r in ix.row..ix.row + ix.height {
386                    let src = (r - t.rect.row) * t.rect.width + (ix.col - t.rect.col);
387                    row_buf.clear();
388                    match p {
389                        Product::SimplexId => {
390                            for v in &t.simplex_id[src..src + ix.width] {
391                                row_buf.extend_from_slice(&v.to_le_bytes());
392                            }
393                        }
394                        _ => {
395                            let s = match p {
396                                Product::RawWse => &t.raw_wse,
397                                Product::Wse => &t.wse,
398                                Product::Depth => &t.depth,
399                                _ => &t.ground,
400                            };
401                            for v in &s[src..src + ix.width] {
402                                row_buf.extend_from_slice(&v.to_le_bytes());
403                            }
404                        }
405                    }
406                    for b in &row_buf {
407                        hash ^= *b as u64;
408                        hash = hash.wrapping_mul(0x100000001b3);
409                    }
410                    let off = (((r - u.window.row) * u.window.width + (ix.col - u.window.col)) * 8)
411                        as u64;
412                    write_at(f, &row_buf, off)
413                        .map_err(|e| io_at(&partial(&Self::data_path(&u.dir, *p)), e))?;
414                    u.bytes += row_buf.len() as u64;
415                    self.bytes_written += row_buf.len() as u64;
416                }
417                rec.fnv1a64[pi] = hash;
418            }
419            u.tiles.push(rec);
420        }
421        self.tiles_written += 1;
422        Ok(())
423    }
424
425    /// Removes a unit's data files (both names) and records `failed`. Returns cleanup problems.
426    fn fail_unit(u: UnitState, e: &MapError, products: &[Product]) -> Vec<String> {
427        let mut problems = Vec::new();
428        drop(u.files);
429        for p in products {
430            let fin = Self::data_path(&u.dir, *p);
431            for path in [partial(&fin), fin] {
432                match fs::remove_file(&path) {
433                    Ok(()) => {}
434                    Err(x) if x.kind() == std::io::ErrorKind::NotFound => {}
435                    Err(x) => problems.push(format!("remove {}: {x}", path.display())),
436                }
437            }
438        }
439        let mut doc = u.header;
440        doc["status"] = "failed".into();
441        doc["error"] = serde_json::json!({"kind": e.kind(), "message": e.to_string()});
442        if let Err(x) = write_json(&u.dir.join("unit.json"), &doc) {
443            problems.push(format!("failed-status unit.json: {x}"));
444        }
445        problems
446    }
447
448    /// Closes a unit: success commits it (see module docs); failure removes partial data.
449    /// A commit failure marks the unit failed and returns the error.
450    pub fn end_unit(
451        &mut self,
452        key: (usize, usize),
453        header: &serde_json::Value,
454        error: Option<&MapError>,
455    ) -> Result<(), MapError> {
456        let Some(mut u) = self.units.remove(&key) else {
457            return Ok(());
458        };
459        u.header = header.clone();
460        if let Some(e) = error {
461            let problems = Self::fail_unit(u, e, &self.products);
462            return match problems.is_empty() {
463                true => Ok(()),
464                false => Err(MapError::Io(format!(
465                    "cleanup of failed unit: {}",
466                    problems.join("; ")
467                ))),
468            };
469        }
470        match self.commit(&mut u) {
471            Ok(()) => {
472                self.unit_files_written += self.products.len() as u64;
473                Ok(())
474            }
475            Err(e) => {
476                let problems = Self::fail_unit(u, &e, &self.products);
477                Err(match problems.is_empty() {
478                    true => e,
479                    false => MapError::Io(format!("{e}; cleanup: {}", problems.join("; "))),
480                })
481            }
482        }
483    }
484
485    fn commit(&mut self, u: &mut UnitState) -> Result<(), MapError> {
486        let expected: usize = u.tiles.iter().map(|t| t.rect.pixels()).sum();
487        if expected != u.window.pixels() {
488            return Err(MapError::Internal(format!(
489                "unit {:?} tiles cover {expected} of {} pixels",
490                u.dir,
491                u.window.pixels()
492            )));
493        }
494        for (p, f) in u.files.drain(..) {
495            let tmp = partial(&Self::data_path(&u.dir, p));
496            f.sync_all().map_err(|e| io_at(&tmp, e))?;
497            drop(f);
498            fs::rename(&tmp, Self::data_path(&u.dir, p)).map_err(|e| io_at(&tmp, e))?;
499        }
500        if !sync_dir(&u.dir)? {
501            self.dir_sync_unsupported += 1;
502        }
503        u.tiles.sort_by_key(|t| (t.tile_row, t.tile_col));
504        let mut doc = u.header.clone();
505        doc["status"] = "complete".into();
506        doc["raw_wse_valid"] = u.tiles.iter().map(|t| t.raw_wse_valid).sum::<u64>().into();
507        doc["wet"] = u.tiles.iter().map(|t| t.wet).sum::<u64>().into();
508        doc["data_bytes"] = u.bytes.into();
509        doc["tiles"] = serde_json::Value::Null; // placeholder: streamed below
510        let names: Vec<&'static str> = self.products.iter().map(|p| p.name()).collect();
511        let map = doc
512            .as_object()
513            .ok_or_else(|| MapError::Internal("unit header is not an object".into()))?;
514        let path = u.dir.join("unit.json");
515        write_atomic(&path, |w| {
516            serde_json::to_writer_pretty(
517                w,
518                &UnitDoc {
519                    doc: map,
520                    tiles: &u.tiles,
521                    names: &names,
522                },
523            )
524            .map_err(|e| io_at(&path, std::io::Error::other(e)))
525        })
526    }
527
528    /// After a session-level error: fails every open unit (removing its data) and returns the
529    /// cleanup problems that could not be resolved (empty when every unit is cleanly failed).
530    pub fn abort_all(&mut self, e: &MapError) -> Vec<String> {
531        let units = std::mem::take(&mut self.units);
532        let mut problems = Vec::new();
533        for (_, u) in units {
534            problems.extend(Self::fail_unit(u, e, &self.products));
535        }
536        problems
537    }
538
539    pub fn open_units(&self) -> usize {
540        self.units.len()
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    #[test]
549    fn rect_intersection() {
550        let a = Rect {
551            col: 0,
552            row: 0,
553            width: 10,
554            height: 10,
555        };
556        let b = Rect {
557            col: 5,
558            row: 8,
559            width: 10,
560            height: 10,
561        };
562        assert_eq!(
563            a.intersect(&b),
564            Some(Rect {
565                col: 5,
566                row: 8,
567                width: 5,
568                height: 2
569            })
570        );
571        let c = Rect {
572            col: 10,
573            row: 0,
574            width: 1,
575            height: 1,
576        };
577        assert_eq!(a.intersect(&c), None);
578    }
579
580    #[test]
581    fn fnv_reference() {
582        assert_eq!(fnv1a64(b""), 0xcbf29ce484222325);
583        assert_eq!(fnv1a64(b"a"), 0xaf63dc4c8601ec8c);
584    }
585
586    fn scratch(name: &str) -> PathBuf {
587        let d =
588            std::env::temp_dir().join(format!("ras2fim-sink-unit-{}-{name}", std::process::id()));
589        let _ = fs::remove_dir_all(&d);
590        d
591    }
592
593    fn tile(profile: usize, rect: Rect) -> TileOut {
594        let n = rect.pixels();
595        TileOut {
596            profile,
597            tile: (0, 0),
598            rect,
599            raw_wse: vec![1.5; n],
600            wse: vec![1.5; n],
601            depth: vec![0.5; n],
602            ground: vec![1.0; n],
603            simplex_id: vec![3; n],
604        }
605    }
606
607    fn names(dir: &Path) -> Vec<String> {
608        let mut v: Vec<String> = fs::read_dir(dir)
609            .unwrap()
610            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
611            .collect();
612        v.sort();
613        v
614    }
615
616    const R: Rect = Rect {
617        col: 0,
618        row: 0,
619        width: 4,
620        height: 2,
621    };
622
623    #[test]
624    fn commit_renames_partials_and_streams_unit_json() {
625        let d = scratch("commit");
626        let mut s = DirSink::create(&d, &[Product::Wse, Product::Depth]).unwrap();
627        let h = serde_json::json!({"schema": "x", "profile": "p"});
628        s.begin_unit((0, 0), "p", "a", Some(R), &h, 1).unwrap();
629        let u = d.join("units/p/a");
630        assert_eq!(
631            names(&u),
632            ["depth.bin.partial", "unit.json", "wse.bin.partial"]
633        );
634        let marker: serde_json::Value =
635            serde_json::from_slice(&fs::read(u.join("unit.json")).unwrap()).unwrap();
636        assert_eq!(marker["status"], "in_progress");
637        s.write_tile(&tile(0, R), 1).unwrap();
638        s.end_unit((0, 0), &h, None).unwrap();
639        assert_eq!(names(&u), ["depth.bin", "unit.json", "wse.bin"]);
640        let doc: serde_json::Value =
641            serde_json::from_slice(&fs::read(u.join("unit.json")).unwrap()).unwrap();
642        assert_eq!(doc["status"], "complete");
643        assert_eq!(
644            doc["tiles"][0]["fnv1a64"]["wse"].as_str().unwrap().len(),
645            16
646        );
647        // Streamed bytes equal the r1 materialized document.
648        let mut r1 = h.clone();
649        r1["status"] = "complete".into();
650        r1["raw_wse_valid"] = 8.into();
651        r1["wet"] = 8.into();
652        r1["data_bytes"] = 128.into();
653        r1["tiles"] = doc["tiles"].clone();
654        assert_eq!(
655            fs::read(u.join("unit.json")).unwrap(),
656            serde_json::to_vec_pretty(&r1).unwrap()
657        );
658        assert_eq!(fs::read(u.join("wse.bin")).unwrap().len(), 64);
659        let _ = fs::remove_dir_all(&d);
660    }
661
662    #[test]
663    fn failed_and_aborted_units_leave_no_data() {
664        let d = scratch("abort");
665        let mut s = DirSink::create(&d, &[Product::Wse]).unwrap();
666        let h = serde_json::json!({"schema": "x"});
667        s.begin_unit((0, 0), "p", "a", Some(R), &h, 1).unwrap();
668        s.begin_unit((0, 1), "p", "b", Some(R), &h, 1).unwrap();
669        s.write_tile(&tile(0, R), 2).unwrap();
670        s.end_unit((0, 0), &h, Some(&MapError::Terrain("bad block".into())))
671            .unwrap();
672        assert!(s.abort_all(&MapError::Io("disk full".into())).is_empty());
673        assert_eq!(s.open_units(), 0);
674        for (aoi, msg) in [("a", "bad block"), ("b", "disk full")] {
675            let u = d.join("units/p").join(aoi);
676            assert_eq!(names(&u), ["unit.json"], "{aoi}");
677            let doc: serde_json::Value =
678                serde_json::from_slice(&fs::read(u.join("unit.json")).unwrap()).unwrap();
679            assert_eq!(doc["status"], "failed");
680            assert!(doc["error"]["message"].as_str().unwrap().contains(msg));
681        }
682        let _ = fs::remove_dir_all(&d);
683    }
684
685    #[test]
686    fn incomplete_coverage_fails_the_unit() {
687        let d = scratch("cover");
688        let mut s = DirSink::create(&d, &[Product::Wse]).unwrap();
689        let h = serde_json::json!({"schema": "x"});
690        s.begin_unit((0, 0), "p", "a", Some(R), &h, 1).unwrap();
691        let e = s.end_unit((0, 0), &h, None).unwrap_err();
692        assert_eq!(e.kind(), "internal");
693        let u = d.join("units/p/a");
694        assert_eq!(names(&u), ["unit.json"]);
695        let _ = fs::remove_dir_all(&d);
696    }
697
698    #[test]
699    fn write_json_is_atomic_replace() {
700        let d = scratch("json");
701        fs::create_dir_all(&d).unwrap();
702        let p = d.join("r.json");
703        write_json(&p, &serde_json::json!({"a": 1})).unwrap();
704        write_json(&p, &serde_json::json!({"a": 2})).unwrap();
705        assert_eq!(names(&d), ["r.json"]);
706        assert_eq!(
707            fs::read(&p).unwrap(),
708            serde_json::to_vec_pretty(&serde_json::json!({"a": 2})).unwrap()
709        );
710        let _ = fs::remove_dir_all(&d);
711    }
712}