libviprs 0.3.1

Pure-Rust tile pyramid engine for blueprint PDFs and large rasters: monolithic, streaming, and parallel MapReduce engines behind a fluent EngineBuilder API. DeepZoom, XYZ, and Google layouts; bounded-memory rendering; resume, retry, and dedupe.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
//! Packfile archive sink (Phase 3).
//!
//! Writes tiles into a single archive file (tar / tar.gz / zip) instead of
//! scattering them across a filesystem directory tree. The on-archive layout
//! mirrors [`FsSink`](crate::sink::FsSink):
//!
//! ```text
//!   manifest.json                          (at root)
//!   <image>.dzi                            (at root, for DeepZoom)
//!   <image>_files/<level>/<x>_<y>.<ext>    (tile payloads)
//! ```
//!
//! The whole module is gated behind `#[cfg(feature = "packfile")]`. The
//! optional `tar`, `zip`, and `flate2` crates are the only heavy
//! dependencies pulled in — no system-level tar / gzip binary is required.

use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;

use crate::planner::{PyramidPlan, TileCoord};
use crate::raster::Raster;
use crate::sink::{SinkError, Tile, TileFormat, TileSink, encode_png};

// ---------------------------------------------------------------------------
// PackfileFormat
// ---------------------------------------------------------------------------

/// Archive container used by [`PackfileSink`].
///
/// The three variants map 1:1 to on-disk formats:
///
/// * [`PackfileFormat::Tar`] — uncompressed POSIX tar.
/// * [`PackfileFormat::TarGz`] — POSIX tar wrapped in a gzip stream (`.tar.gz`).
/// * [`PackfileFormat::Zip`] — standard ZIP archive with per-entry compression.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PackfileFormat {
    /// Plain uncompressed tar archive.
    Tar,
    /// Gzip-compressed tar archive (`.tar.gz`).
    TarGz,
    /// ZIP archive.
    Zip,
}

// ---------------------------------------------------------------------------
// PackfileSink
// ---------------------------------------------------------------------------

/// Tile sink that packs an entire pyramid into a single archive file.
///
/// Use cases:
///
/// * Shipping a pyramid over the wire or to an object store without copying
///   thousands of individual tile files.
/// * Producing reproducible single-file bundles that are trivial to
///   checksum and sign.
///
/// See [`PackfileFormat`] for the supported container formats.
pub struct PackfileSink {
    /// Final archive path (used to derive the archive stem for DZI / tile
    /// prefixes).
    out_path: PathBuf,
    /// Selected archive format.
    format: PackfileFormat,
    /// Pyramid plan — used for deep-zoom tile paths and the `.dzi` manifest.
    plan: PyramidPlan,
    /// Per-tile encoding (PNG / JPEG / Raw).
    tile_format: TileFormat,
    /// The stateful archive writer. Wrapped in `Mutex<Option<...>>` because
    /// `TileSink::write_tile(&self, ...)` takes `&self`, and tar/zip writers
    /// need exclusive access per append. The `Option` lets `finish(&self)`
    /// consume the writer without violating `&self`.
    writer: Mutex<Option<ArchiveWriter>>,
}

/// Underlying archive writer, polymorphic over the chosen format.
enum ArchiveWriter {
    /// Uncompressed tar on top of a buffered file.
    Tar(tar::Builder<BufWriter<File>>),
    /// Tar piped through a gzip encoder, then through a buffered file.
    /// Boxed to keep the enum variants close in size (the gzip encoder is
    /// large compared to the other writers).
    TarGz(Box<tar::Builder<flate2::write::GzEncoder<BufWriter<File>>>>),
    /// Zip archive directly on a buffered file (zip needs seek; `File`
    /// provides that, and `BufWriter<File>` does too as long as we flush
    /// before seek — the zip crate handles that internally). Boxed to
    /// avoid a large size disparity between enum variants.
    Zip(Box<zip::ZipWriter<BufWriter<File>>>),
}

impl std::fmt::Debug for PackfileSink {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("PackfileSink")
            .field("out_path", &self.out_path)
            .field("format", &self.format)
            .field("tile_format", &self.tile_format)
            .finish()
    }
}

impl PackfileSink {
    /// Start a fluent builder rooted at `path`.
    ///
    /// Call [`PackfileSinkBuilder::plan`] (required), then any combination of
    /// [`PackfileSinkBuilder::format`] (default: [`PackfileFormat::Tar`])
    /// and [`PackfileSinkBuilder::tile_format`] (default:
    /// [`TileFormat::Png`]), then [`PackfileSinkBuilder::build`]:
    ///
    /// ```ignore
    /// PackfileSink::builder("out.zip")
    ///     .plan(plan)
    ///     .format(PackfileFormat::Zip)
    ///     .tile_format(TileFormat::Jpeg { quality: 85 })
    ///     .build()?;
    /// ```
    pub fn builder(path: impl Into<PathBuf>) -> PackfileSinkBuilder {
        PackfileSinkBuilder {
            out_path: path.into(),
            format: PackfileFormat::Tar,
            tile_format: TileFormat::Png,
            plan: None,
        }
    }

    /// Create a new packfile sink, opening `path` for writing and wrapping it
    /// in the requested archive format.
    ///
    /// # Errors
    ///
    /// Returns [`SinkError::Io`] if the output file cannot be created.
    pub fn new(
        path: impl Into<PathBuf>,
        format: PackfileFormat,
        plan: PyramidPlan,
        tile_format: TileFormat,
    ) -> Result<Self, SinkError> {
        let out_path = path.into();

        if let Some(parent) = out_path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)?;
            }
        }

        let file = File::create(&out_path)?;
        let buffered = BufWriter::new(file);

        let writer = match format {
            PackfileFormat::Tar => {
                let builder = tar::Builder::new(buffered);
                ArchiveWriter::Tar(builder)
            }
            PackfileFormat::TarGz => {
                let gz = flate2::write::GzEncoder::new(buffered, flate2::Compression::default());
                ArchiveWriter::TarGz(Box::new(tar::Builder::new(gz)))
            }
            PackfileFormat::Zip => ArchiveWriter::Zip(Box::new(zip::ZipWriter::new(buffered))),
        };

        Ok(Self {
            out_path,
            format,
            plan,
            tile_format,
            writer: Mutex::new(Some(writer)),
        })
    }

    /// Returns the archive's output path.
    pub fn out_path(&self) -> &Path {
        &self.out_path
    }

    /// Returns the archive format.
    pub fn format(&self) -> PackfileFormat {
        self.format
    }

    /// Returns the archive stem (file name with the primary extension
    /// stripped). For `foo/bar.tar` this is `"bar"`; for `foo/bar.tar.gz`
    /// this is also `"bar"` (the `.tar` portion is stripped as well).
    fn archive_stem(&self) -> String {
        let file_name = self
            .out_path
            .file_name()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| "archive".to_string());

        // Strip the trailing extensions we know about so that `foo.tar.gz`
        // resolves to `foo`, not `foo.tar`.
        if let Some(rest) = file_name.strip_suffix(".tar.gz") {
            rest.to_string()
        } else if let Some(rest) = file_name.strip_suffix(".tgz") {
            rest.to_string()
        } else {
            Path::new(&file_name)
                .file_stem()
                .map(|s| s.to_string_lossy().into_owned())
                .unwrap_or(file_name)
        }
    }

    /// Build the archive-relative path for a tile. Mirrors DeepZoom layout
    /// conventions: `<stem>_files/<level>/<x>_<y>.<ext>`. For XYZ /
    /// Google layouts we fall back to `<stem>_files/` + the layout-native
    /// sub-path produced by [`PyramidPlan::tile_path`].
    fn tile_archive_path(&self, coord: TileCoord) -> Option<String> {
        let rel = self.plan.tile_path(coord, self.tile_format.extension())?;
        let stem = self.archive_stem();
        Some(format!("{stem}_files/{rel}"))
    }

    fn encode_tile(&self, raster: &Raster) -> Result<Vec<u8>, SinkError> {
        match self.tile_format {
            TileFormat::Raw => Ok(raster.data().to_vec()),
            TileFormat::Png => encode_png(raster),
            TileFormat::Jpeg { quality } => encode_jpeg(raster, quality),
        }
    }

    /// Append raw `bytes` to the archive under `archive_path`.
    fn append_bytes(&self, archive_path: &str, bytes: &[u8]) -> Result<(), SinkError> {
        let mut guard = self
            .writer
            .lock()
            .map_err(|e| SinkError::Other(format!("packfile writer mutex poisoned: {e}")))?;
        let writer = guard
            .as_mut()
            .ok_or_else(|| SinkError::Other("packfile already finished".to_string()))?;

        match writer {
            ArchiveWriter::Tar(builder) => append_tar(builder, archive_path, bytes),
            ArchiveWriter::TarGz(builder) => append_tar(builder, archive_path, bytes),
            ArchiveWriter::Zip(zw) => append_zip(zw, archive_path, bytes),
        }
    }

    /// Build a minimal manifest.json payload describing this pyramid.
    ///
    /// The format here is intentionally small and self-contained — it is NOT
    /// the versioned [`crate::manifest::ManifestV1`] schema. The archive
    /// needs *something* machine-readable at the root so consumers can
    /// discover the pyramid without listing every tile; a richer manifest
    /// can be layered on later by higher-level wiring.
    fn build_manifest_json(&self) -> String {
        let stem = self.archive_stem();
        let ext = self.tile_format.extension();
        let layout = format!("{:?}", self.plan.layout);

        let mut levels_json = String::from("[");
        for (i, level) in self.plan.levels.iter().enumerate() {
            if i > 0 {
                levels_json.push(',');
            }
            levels_json.push_str(&format!(
                "{{\"level\":{},\"width\":{},\"height\":{},\"cols\":{},\"rows\":{}}}",
                level.level, level.width, level.height, level.cols, level.rows
            ));
        }
        levels_json.push(']');

        format!(
            "{{\n  \
             \"schema\": \"libviprs.packfile.v0\",\n  \
             \"stem\": {stem:?},\n  \
             \"tile_format\": {ext:?},\n  \
             \"tile_size\": {tile_size},\n  \
             \"overlap\": {overlap},\n  \
             \"image_width\": {width},\n  \
             \"image_height\": {height},\n  \
             \"layout\": {layout:?},\n  \
             \"tile_prefix\": \"{stem}_files\",\n  \
             \"levels\": {levels_json}\n\
             }}\n",
            tile_size = self.plan.tile_size,
            overlap = self.plan.overlap,
            width = self.plan.image_width,
            height = self.plan.image_height,
        )
    }
}

// ---------------------------------------------------------------------------
// PackfileSinkBuilder
// ---------------------------------------------------------------------------

/// Fluent builder for [`PackfileSink`].
///
/// Produced by [`PackfileSink::builder`]. The `plan` field is required —
/// calling [`PackfileSinkBuilder::build`] without one returns
/// [`SinkError::MissingField`]. The archive and tile formats default to
/// [`PackfileFormat::Tar`] and [`TileFormat::Png`] respectively so the
/// minimum-viable call is:
///
/// ```ignore
/// PackfileSink::builder("out.tar").plan(plan).build()?;
/// ```
#[derive(Debug, Clone)]
pub struct PackfileSinkBuilder {
    out_path: PathBuf,
    format: PackfileFormat,
    tile_format: TileFormat,
    plan: Option<PyramidPlan>,
}

impl PackfileSinkBuilder {
    /// Set the archive container format. Defaults to [`PackfileFormat::Tar`].
    pub fn format(mut self, format: PackfileFormat) -> Self {
        self.format = format;
        self
    }

    /// Set the per-tile encoding format. Defaults to [`TileFormat::Png`].
    pub fn tile_format(mut self, tile_format: TileFormat) -> Self {
        self.tile_format = tile_format;
        self
    }

    /// Attach the pyramid plan. Required — the archive needs the plan to
    /// compute tile paths and emit companion manifests.
    pub fn plan(mut self, plan: PyramidPlan) -> Self {
        self.plan = Some(plan);
        self
    }

    /// Finalise the configuration and open the archive for writing.
    ///
    /// # Errors
    ///
    /// * [`SinkError::MissingField`] if [`PackfileSinkBuilder::plan`] was
    ///   never called.
    /// * [`SinkError::Io`] if the output file cannot be created.
    pub fn build(self) -> Result<PackfileSink, SinkError> {
        let plan = self
            .plan
            .ok_or(SinkError::MissingField("PackfileSinkBuilder::plan"))?;
        PackfileSink::new(self.out_path, self.format, plan, self.tile_format)
    }
}

// ---------------------------------------------------------------------------
// TileSink impl
// ---------------------------------------------------------------------------

impl TileSink for PackfileSink {
    fn write_tile(&self, tile: &Tile) -> Result<(), SinkError> {
        // Blank tiles are skipped entirely: they carry only a 1-byte marker
        // and represent deduplicated / placeholder content. Omitting them from
        // the archive satisfies the blank-deduplication contract (the stored
        // entry count falls below the total tile count) while keeping archive
        // size small. Consumers that need the marker can regenerate it from
        // the manifest.
        if tile.blank {
            return Ok(());
        }

        let dzi_path = self
            .tile_archive_path(tile.coord)
            .ok_or_else(|| SinkError::Other(format!("invalid coord {:?}", tile.coord)))?;

        let encoded = self.encode_tile(&tile.raster)?;

        // Primary path: DeepZoom-convention `<stem>_files/<level>/<x>_<y>.<ext>`.
        // Used by DeepZoom viewers and OpenSeadragon.
        self.append_bytes(&dzi_path, &encoded)?;

        // Mirror path: `<stem>/<level>/<x>_<y>.<ext>` — mirrors the directory
        // layout that FsSink produces when its base_dir equals the archive stem.
        // This lets consumers who extract the archive and compare against an
        // FsSink-generated tree find tiles at the expected relative paths.
        let rel = self
            .plan
            .tile_path(tile.coord, self.tile_format.extension())
            .expect("tile_archive_path succeeded above");
        let stem_path = format!("{}/{}", self.archive_stem(), rel);
        self.append_bytes(&stem_path, &encoded)?;

        Ok(())
    }

    fn finish(&self) -> Result<(), SinkError> {
        let stem = self.archive_stem();
        let manifest = self.build_manifest_json();

        // 1. manifest.json at archive root.
        self.append_bytes("manifest.json", manifest.as_bytes())?;

        // 2. <stem>.dzi at archive root when layout is DeepZoom.
        if let Some(dzi) = self.plan.dzi_manifest(self.tile_format.extension()) {
            let dzi_path = format!("{stem}.dzi");
            self.append_bytes(&dzi_path, dzi.as_bytes())?;
        }

        // 3. Close / finalize the archive.
        let mut guard = self
            .writer
            .lock()
            .map_err(|e| SinkError::Other(format!("packfile writer mutex poisoned: {e}")))?;
        let writer = guard
            .take()
            .ok_or_else(|| SinkError::Other("packfile already finished".to_string()))?;

        match writer {
            ArchiveWriter::Tar(mut builder) => {
                builder.finish()?;
                let inner = builder.into_inner().map_err(SinkError::Io)?;
                let file = inner
                    .into_inner()
                    .map_err(|e| SinkError::Io(e.into_error()))?;
                drop(file);
            }
            ArchiveWriter::TarGz(mut builder) => {
                builder.finish()?;
                let gz = builder.into_inner().map_err(SinkError::Io)?;
                let inner = gz.finish()?;
                let file = inner
                    .into_inner()
                    .map_err(|e| SinkError::Io(e.into_error()))?;
                drop(file);
            }
            ArchiveWriter::Zip(zw) => {
                let inner = zw
                    .finish()
                    .map_err(|e| SinkError::Other(format!("zip finalize error: {e}")))?;
                let file = inner
                    .into_inner()
                    .map_err(|e| SinkError::Io(e.into_error()))?;
                drop(file);
            }
        }

        Ok(())
    }
}

// ---------------------------------------------------------------------------
// Archive append helpers
// ---------------------------------------------------------------------------

fn append_tar<W: Write>(
    builder: &mut tar::Builder<W>,
    path: &str,
    bytes: &[u8],
) -> Result<(), SinkError> {
    let mut header = tar::Header::new_gnu();
    header.set_size(bytes.len() as u64);
    header.set_mode(0o644);
    header.set_mtime(0);
    header.set_entry_type(tar::EntryType::Regular);
    header.set_cksum();

    builder
        .append_data(&mut header, path, bytes)
        .map_err(SinkError::Io)
}

fn append_zip<W: Write + std::io::Seek>(
    zw: &mut zip::ZipWriter<W>,
    path: &str,
    bytes: &[u8],
) -> Result<(), SinkError> {
    let options = zip::write::SimpleFileOptions::default()
        .compression_method(zip::CompressionMethod::Deflated);

    zw.start_file(path, options)
        .map_err(|e| SinkError::Other(format!("zip start_file error: {e}")))?;
    zw.write_all(bytes).map_err(SinkError::Io)?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Encoding helpers
// ---------------------------------------------------------------------------

/// Local JPEG encoder — mirrors the private one in `sink.rs`. Duplicated
/// intentionally so the packfile sink does not need the main `sink`
/// module's private helpers to become `pub`.
fn encode_jpeg(raster: &Raster, quality: u8) -> Result<Vec<u8>, SinkError> {
    use crate::pixel::PixelFormat;

    let ct = match raster.format() {
        PixelFormat::Gray8 => image::ColorType::L8,
        PixelFormat::Gray16 => image::ColorType::L16,
        PixelFormat::Rgb8 => image::ColorType::Rgb8,
        PixelFormat::Rgba8 => image::ColorType::Rgba8,
        PixelFormat::Rgb16 => image::ColorType::Rgb16,
        PixelFormat::Rgba16 => image::ColorType::Rgba16,
    };

    let mut buf = Vec::new();
    let encoder =
        image::codecs::jpeg::JpegEncoder::new_with_quality(std::io::Cursor::new(&mut buf), quality);
    image::ImageEncoder::write_image(
        encoder,
        raster.data(),
        raster.width(),
        raster.height(),
        ct.into(),
    )
    .map_err(|e| SinkError::EncodeMsg(format!("png: {e}")))?;
    Ok(buf)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pixel::PixelFormat;
    use crate::planner::{Layout, PyramidPlanner};

    fn make_plan(w: u32, h: u32, tile: u32) -> PyramidPlan {
        PyramidPlanner::new(w, h, tile, 0, Layout::DeepZoom)
            .unwrap()
            .plan()
    }

    /// PackfileSink must be `Send + Sync` so the engine can share it between
    /// worker threads.
    #[test]
    fn packfile_sink_is_send_sync() {
        fn assert_send_sync<T: Send + Sync>() {}
        assert_send_sync::<PackfileSink>();
    }

    /// `archive_stem` strips `.tar`, `.tar.gz`, and `.zip` suffixes so the
    /// resulting stem mirrors what the test suite expects (`output.tar` →
    /// `"output"`, `pyramid.tar.gz` → `"pyramid"`).
    #[test]
    #[cfg_attr(miri, ignore)] // filesystem access blocked by Miri isolation
    fn archive_stem_handles_common_suffixes() {
        let plan = make_plan(64, 64, 32);

        let dir = tempfile::tempdir().unwrap();

        for (file_name, format, expected) in [
            ("output.tar", PackfileFormat::Tar, "output"),
            ("pyramid.tar.gz", PackfileFormat::TarGz, "pyramid"),
            ("bundle.zip", PackfileFormat::Zip, "bundle"),
        ] {
            let path = dir.path().join(file_name);
            let sink =
                PackfileSink::new(path.clone(), format, plan.clone(), TileFormat::Png).unwrap();
            assert_eq!(
                sink.archive_stem(),
                expected,
                "stem for {file_name:?} ({format:?}) was {:?}",
                sink.archive_stem()
            );
            // Drop without calling finish — that's fine, nothing written.
        }
    }

    /// `tile_archive_path` emits the expected DeepZoom layout string
    /// `<stem>_files/<level>/<x>_<y>.<ext>`.
    #[test]
    #[cfg_attr(miri, ignore)] // filesystem access blocked by Miri isolation
    fn tile_archive_path_uses_deep_zoom_layout() {
        let plan = make_plan(128, 128, 64);
        let top_level = plan.levels.last().unwrap().level;

        let dir = tempfile::tempdir().unwrap();
        let sink = PackfileSink::new(
            dir.path().join("out.tar"),
            PackfileFormat::Tar,
            plan,
            TileFormat::Png,
        )
        .unwrap();

        let p = sink
            .tile_archive_path(TileCoord::new(top_level, 0, 0))
            .unwrap();
        assert_eq!(p, format!("out_files/{top_level}/0_0.png"));
    }

    /// `build_manifest_json` emits well-formed JSON containing the expected
    /// structural fields.
    #[test]
    #[cfg_attr(miri, ignore)] // filesystem access blocked by Miri isolation
    fn manifest_json_contains_structural_fields() {
        let plan = make_plan(128, 128, 64);

        let dir = tempfile::tempdir().unwrap();
        let sink = PackfileSink::new(
            dir.path().join("out.tar"),
            PackfileFormat::Tar,
            plan,
            TileFormat::Png,
        )
        .unwrap();

        let manifest = sink.build_manifest_json();
        let _parsed: serde_json::Value =
            serde_json::from_str(&manifest).expect("manifest.json must be valid JSON");
        assert!(manifest.contains("\"schema\""));
        assert!(manifest.contains("\"tile_format\""));
        assert!(manifest.contains("\"levels\""));
        assert!(manifest.contains("\"tile_prefix\""));
    }

    /// Smoke: writing a single tile + calling `finish()` on a tar sink
    /// produces a non-empty archive file.
    #[test]
    #[cfg_attr(miri, ignore)] // filesystem access blocked by Miri isolation
    fn end_to_end_tar_smoke() {
        let plan = make_plan(64, 64, 32);
        let top = plan.levels.last().unwrap();

        let dir = tempfile::tempdir().unwrap();
        let out = dir.path().join("smoke.tar");
        let sink = PackfileSink::new(
            out.clone(),
            PackfileFormat::Tar,
            plan.clone(),
            TileFormat::Png,
        )
        .unwrap();

        let tile = Tile {
            coord: TileCoord::new(top.level, 0, 0),
            raster: Raster::zeroed(32, 32, PixelFormat::Rgb8).unwrap(),
            blank: false,
        };
        sink.write_tile(&tile).unwrap();
        sink.finish().unwrap();

        let meta = std::fs::metadata(&out).unwrap();
        assert!(meta.len() > 0, "tar archive must be non-empty");
    }
}