Skip to main content

setzer_model/
project.rs

1//! The `.kernsetzer` project file: a zip archive with
2//!
3//! ```text
4//! meta.json          { "format_version": 2, "created_with": "kernsetzer 0.1.0", "modified": "…" }
5//! book.json          the Book, pretty-printed
6//! assets/<id>        images, referenced by id from book.json
7//! ```
8//!
9//! plus `book.json` (de)serialization with schema migrations, and the
10//! autosave sidecar `<name>.kernsetzer.autosave` next to the project.
11//!
12//! Saving writes a temporary sibling and renames it over the project, so a
13//! crash mid-save never leaves a half-written project behind.
14
15use crate::asset::{Asset, ImageFormat};
16use crate::{Book, FORMAT_VERSION};
17use serde::{Deserialize, Serialize};
18use serde_json::{Map, Value};
19use std::ffi::OsString;
20use std::fs::{self, File};
21use std::io::{BufReader, BufWriter, Read, Seek, Write};
22use std::path::{Path, PathBuf};
23use zip::write::SimpleFileOptions;
24use zip::{CompressionMethod, DateTime, ZipArchive, ZipWriter};
25
26const BOOK_JSON: &str = "book.json";
27const META_JSON: &str = "meta.json";
28const ASSETS_DIR: &str = "assets/";
29
30/// Largest `book.json` / `meta.json` accepted (a 100k-word novel is ~2 MB).
31pub const MAX_JSON_BYTES: u64 = 256 * 1024 * 1024;
32/// Largest single asset accepted (same limit as the .docx importer).
33pub const MAX_ASSET_BYTES: u64 = 100 * 1024 * 1024;
34
35/// The project file's extension, without the dot.
36pub const EXTENSION: &str = "kernsetzer";
37/// The extension of project files from before the rename. Files with it
38/// are still opened.
39pub const OLD_EXTENSION: &str = "setzer";
40
41/// The build that writes a file, as recorded in `meta.json`.
42pub const CREATED_WITH: &str = concat!("kernsetzer ", env!("CARGO_PKG_VERSION"));
43
44#[derive(Debug, thiserror::Error)]
45pub enum ProjectError {
46    #[error("invalid JSON: {0}")]
47    Json(#[from] serde_json::Error),
48    #[error("book.json has no valid format_version")]
49    MissingVersion,
50    #[error(
51        "this project was saved by a newer Kernsetzer (format {found}, this version reads up to {max}). Update Kernsetzer to open it.",
52        max = FORMAT_VERSION
53    )]
54    TooNew { found: u64 },
55    #[error("this is not a Kernsetzer project (no book.json inside)")]
56    NotAProject,
57    #[error("the project file is damaged: {0}")]
58    Zip(#[from] zip::result::ZipError),
59    #[error("{name} in the project is larger than {limit} bytes")]
60    TooLarge { name: String, limit: u64 },
61    #[error("\"{0}\" cannot be used as an image file name in a project")]
62    InvalidAssetId(String),
63    #[error("two images in the project are both named \"{0}\"")]
64    DuplicateAsset(String),
65    #[error(transparent)]
66    Io(#[from] std::io::Error),
67}
68
69/// Contents of `meta.json`: facts about the file, not about the book.
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71pub struct Meta {
72    /// Container format; always written as [`FORMAT_VERSION`].
73    pub format_version: u32,
74    /// The build that created the project, e.g. `kernsetzer 0.1.0`. Kept
75    /// across saves (useful in support requests).
76    #[serde(default)]
77    pub created_with: String,
78    /// When the file was last saved, RFC 3339 in UTC.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub modified: Option<String>,
81    /// Fields written by newer builds, kept on round-trip.
82    #[serde(flatten)]
83    pub extra: Map<String, Value>,
84}
85
86impl Meta {
87    /// Meta for a project created by this build.
88    pub fn new() -> Self {
89        Self {
90            format_version: FORMAT_VERSION,
91            created_with: CREATED_WITH.into(),
92            modified: None,
93            extra: Map::new(),
94        }
95    }
96}
97
98impl Default for Meta {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104/// A book with its images and file metadata: what a `.kernsetzer` holds.
105#[derive(Debug, Clone, PartialEq)]
106pub struct Project {
107    pub book: Book,
108    /// In file order; ids are unique and valid ([`Asset::is_valid_id`]).
109    pub assets: Vec<Asset>,
110    pub meta: Meta,
111}
112
113impl Project {
114    pub fn new(book: Book, assets: Vec<Asset>) -> Self {
115        Self {
116            book,
117            assets,
118            meta: Meta::new(),
119        }
120    }
121
122    pub fn asset(&self, id: &str) -> Option<&Asset> {
123        self.assets.iter().find(|a| a.id == id)
124    }
125
126    /// Read a `.kernsetzer` archive. `book.json` is migrated to the current
127    /// format. A missing or unreadable `meta.json` is not an error (the
128    /// book is what matters); entries under `assets/` whose names are not
129    /// valid ids (`.DS_Store`, sub-folders) and unknown entries are
130    /// skipped. Asset metadata is derived from the bytes.
131    pub fn read<R: Read + Seek>(reader: R) -> Result<Self, ProjectError> {
132        let mut zip = ZipArchive::new(reader)?;
133        let mut book = None;
134        let mut meta = None;
135        let mut assets: Vec<Asset> = Vec::new();
136        for i in 0..zip.len() {
137            let mut file = zip.by_index(i)?;
138            if file.is_dir() {
139                continue;
140            }
141            let name = file.name()?.into_owned();
142            if name == BOOK_JSON {
143                let bytes = read_limited(&mut file, &name, MAX_JSON_BYTES)?;
144                book = Some(book_from_json(&String::from_utf8_lossy(&bytes))?);
145            } else if name == META_JSON {
146                let bytes = read_limited(&mut file, &name, MAX_JSON_BYTES)?;
147                meta = serde_json::from_slice::<Meta>(&bytes).ok();
148            } else if let Some(id) = name.strip_prefix(ASSETS_DIR) {
149                if !Asset::is_valid_id(id) || assets.iter().any(|a| a.id == id) {
150                    continue;
151                }
152                let id = id.to_owned();
153                let bytes = read_limited(&mut file, &name, MAX_ASSET_BYTES)?;
154                assets.push(Asset::new(id, bytes));
155            }
156        }
157        let meta = meta.unwrap_or_else(|| Meta {
158            created_with: "unknown".into(),
159            ..Meta::new()
160        });
161        if meta.format_version > FORMAT_VERSION {
162            return Err(ProjectError::TooNew {
163                found: meta.format_version.into(),
164            });
165        }
166        Ok(Self {
167            book: book.ok_or(ProjectError::NotAProject)?,
168            assets,
169            meta,
170        })
171    }
172
173    /// Write the archive as is (`meta.modified` unchanged).
174    pub fn write<W: Write + Seek>(&self, writer: W) -> Result<W, ProjectError> {
175        self.write_with(writer, self.meta.modified.as_deref())
176    }
177
178    fn write_with<W: Write + Seek>(
179        &self,
180        writer: W,
181        modified: Option<&str>,
182    ) -> Result<W, ProjectError> {
183        let mut seen: Vec<&str> = Vec::with_capacity(self.assets.len());
184        for a in &self.assets {
185            if !Asset::is_valid_id(&a.id) {
186                return Err(ProjectError::InvalidAssetId(a.id.clone()));
187            }
188            if seen.contains(&a.id.as_str()) {
189                return Err(ProjectError::DuplicateAsset(a.id.clone()));
190            }
191            seen.push(&a.id);
192        }
193        let meta = Meta {
194            format_version: FORMAT_VERSION,
195            modified: modified.map(str::to_owned),
196            ..self.meta.clone()
197        };
198
199        // Fixed timestamps keep the archive byte-identical for equal input;
200        // the real save time is in meta.json.
201        let opts = SimpleFileOptions::default().last_modified_time(DateTime::DEFAULT);
202        let deflated = opts.compression_method(CompressionMethod::Deflated);
203        let stored = opts.compression_method(CompressionMethod::Stored);
204        let mut zip = ZipWriter::new(writer);
205        zip.start_file(META_JSON, deflated)?;
206        zip.write_all(serde_json::to_string_pretty(&meta)?.as_bytes())?;
207        zip.start_file(BOOK_JSON, deflated)?;
208        zip.write_all(book_to_json(&self.book)?.as_bytes())?;
209        for a in &self.assets {
210            // PNG, JPEG, GIF and WebP are compressed already.
211            let method = match ImageFormat::sniff(&a.bytes) {
212                ImageFormat::Supported { ext, .. } if ext != "svg" => stored,
213                _ => deflated,
214            };
215            zip.start_file(format!("{ASSETS_DIR}{}", a.id), method)?;
216            zip.write_all(&a.bytes)?;
217        }
218        Ok(zip.finish()?)
219    }
220
221    pub fn open(path: &Path) -> Result<Self, ProjectError> {
222        Self::read(BufReader::new(File::open(path)?))
223    }
224
225    /// Save to `path` (atomically), set `meta.modified`, and remove the
226    /// autosave sidecar: after a clean save there is nothing to recover.
227    pub fn save(&mut self, path: &Path) -> Result<(), ProjectError> {
228        let now = now_rfc3339();
229        write_atomically(path, |f| self.write_with(f, Some(&now)).map(drop))?;
230        self.meta.modified = Some(now);
231        discard_autosave(path)
232    }
233
234    /// Write the autosave sidecar for the project at `path`
235    /// ([`autosave_path`]); the project file itself is not touched.
236    pub fn autosave(&self, path: &Path) -> Result<(), ProjectError> {
237        let now = now_rfc3339();
238        write_atomically(&autosave_path(path), |f| {
239            self.write_with(f, Some(&now)).map(drop)
240        })
241    }
242}
243
244/// The autosave sidecar for a project: `Roman.kernsetzer` →
245/// `Roman.kernsetzer.autosave`, in the same folder. If it exists when a
246/// project is opened, the last session ended without a clean save.
247pub fn autosave_path(project: &Path) -> PathBuf {
248    with_suffix(project, ".autosave")
249}
250
251/// Remove the autosave sidecar, if there is one.
252pub fn discard_autosave(project: &Path) -> Result<(), ProjectError> {
253    match fs::remove_file(autosave_path(project)) {
254        Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e.into()),
255        _ => Ok(()),
256    }
257}
258
259fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
260    let mut s = OsString::from(path.as_os_str());
261    s.push(suffix);
262    PathBuf::from(s)
263}
264
265/// Write `path` via a temporary sibling that is synced and renamed over
266/// it, so readers see the old file or the new one, never a partial one.
267fn write_atomically(
268    path: &Path,
269    write: impl FnOnce(&mut BufWriter<File>) -> Result<(), ProjectError>,
270) -> Result<(), ProjectError> {
271    let tmp = with_suffix(path, ".saving");
272    let result = (|| {
273        let mut out = BufWriter::new(File::create(&tmp)?);
274        write(&mut out)?;
275        out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
276        fs::rename(&tmp, path)?;
277        Ok(())
278    })();
279    if result.is_err() {
280        let _ = fs::remove_file(&tmp);
281    }
282    result
283}
284
285fn read_limited(file: &mut impl Read, name: &str, limit: u64) -> Result<Vec<u8>, ProjectError> {
286    let mut bytes = Vec::new();
287    file.take(limit + 1).read_to_end(&mut bytes)?;
288    if bytes.len() as u64 > limit {
289        return Err(ProjectError::TooLarge {
290            name: name.into(),
291            limit,
292        });
293    }
294    Ok(bytes)
295}
296
297/// The current time as RFC 3339 in UTC, to the second.
298pub fn now_rfc3339() -> String {
299    let secs = std::time::SystemTime::now()
300        .duration_since(std::time::UNIX_EPOCH)
301        .map_or(0, |d| d.as_secs());
302    rfc3339(secs)
303}
304
305fn rfc3339(unix_secs: u64) -> String {
306    let days = (unix_secs / 86_400) as i64;
307    let rem = unix_secs % 86_400;
308    // Civil date from days since 1970-01-01 (H. Hinnant, `civil_from_days`).
309    let z = days + 719_468;
310    let era = z.div_euclid(146_097);
311    let doe = z.rem_euclid(146_097);
312    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
313    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
314    let mp = (5 * doy + 2) / 153;
315    let day = doy - (153 * mp + 2) / 5 + 1;
316    let month = if mp < 10 { mp + 3 } else { mp - 9 };
317    let year = yoe + era * 400 + i64::from(month <= 2);
318    format!(
319        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
320        rem / 3600,
321        rem / 60 % 60,
322        rem % 60
323    )
324}
325
326/// Parse `book.json`, migrating older formats to the current one.
327pub fn book_from_json(s: &str) -> Result<Book, ProjectError> {
328    let v: Value = serde_json::from_str(s)?;
329    Ok(serde_json::from_value(migrate(v)?)?)
330}
331
332/// One schema step: `MIGRATIONS[i]` turns a version `i + 1` `book.json`
333/// into version `i + 2`. Each step is a plain function on JSON, written
334/// once and never changed afterwards (old files stay old). When the
335/// schema changes incompatibly: bump [`FORMAT_VERSION`], append the step,
336/// and commit a fixture of the old version under `tests/fixtures/`.
337pub type Migration = fn(Value) -> Result<Value, ProjectError>;
338
339pub const MIGRATIONS: &[Migration] = &[migrate_v1_to_v2];
340
341/// Version 2 added fields only (picture size and placement, and the table
342/// block), each optional and skipped when default, so a version 1
343/// `book.json` is already a valid version 2 one. The bump exists so that a
344/// version 1 build refuses a file that may use them instead of drawing
345/// a bleed or beside picture as a centred one.
346fn migrate_v1_to_v2(v: Value) -> Result<Value, ProjectError> {
347    Ok(v)
348}
349
350const _: () = assert!(MIGRATIONS.len() + 1 == FORMAT_VERSION as usize);
351
352/// Bring a `book.json` value of any supported version to the current
353/// format, applying the migrations in order.
354pub fn migrate(v: Value) -> Result<Value, ProjectError> {
355    migrate_with(v, MIGRATIONS)
356}
357
358fn migrate_with(mut v: Value, steps: &[Migration]) -> Result<Value, ProjectError> {
359    let current = steps.len() as u64 + 1;
360    let version = v
361        .get("format_version")
362        .and_then(Value::as_u64)
363        .ok_or(ProjectError::MissingVersion)?;
364    if version == 0 {
365        return Err(ProjectError::MissingVersion);
366    }
367    if version > current {
368        return Err(ProjectError::TooNew { found: version });
369    }
370    for step in steps.iter().skip((version - 1) as usize) {
371        v = step(v)?;
372    }
373    match v.as_object_mut() {
374        Some(obj) => {
375            obj.insert("format_version".into(), Value::from(current));
376            Ok(v)
377        }
378        None => Err(ProjectError::MissingVersion),
379    }
380}
381
382pub fn book_to_json(book: &Book) -> Result<String, ProjectError> {
383    Ok(serde_json::to_string_pretty(book)?)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::asset::testdata::png_3x2_300dpi;
390    use crate::{Block, Metadata, Section};
391    use std::io::Cursor;
392
393    fn book() -> Book {
394        let mut b = Book::new(Metadata {
395            title: "T".into(),
396            ..Default::default()
397        });
398        b.sections.push(Section::chapter(
399            "Eins",
400            vec![Block::image("map.png", "Karte", None)],
401        ));
402        b
403    }
404
405    fn project() -> Project {
406        let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="4" height="4"/>"#;
407        Project::new(
408            book(),
409            vec![
410                Asset::new("map.png", png_3x2_300dpi()),
411                Asset::new("ornament.svg", svg.to_vec()),
412            ],
413        )
414    }
415
416    fn to_bytes(p: &Project) -> Vec<u8> {
417        p.write(Cursor::new(Vec::new())).unwrap().into_inner()
418    }
419
420    fn zip_of(entries: &[(&str, &[u8])]) -> Vec<u8> {
421        let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
422        for (name, bytes) in entries {
423            zip.start_file(*name, SimpleFileOptions::default()).unwrap();
424            zip.write_all(bytes).unwrap();
425        }
426        zip.finish().unwrap().into_inner()
427    }
428
429    #[test]
430    fn round_trip() {
431        let b = book();
432        let s = book_to_json(&b).unwrap();
433        assert_eq!(book_from_json(&s).unwrap(), b);
434    }
435
436    #[test]
437    fn rejects_newer() {
438        let err = book_from_json(r#"{"format_version": 999}"#).unwrap_err();
439        assert!(matches!(err, ProjectError::TooNew { found: 999 }));
440        let err = book_from_json(r#"{"format_version": 3}"#).unwrap_err();
441        assert!(matches!(err, ProjectError::TooNew { found: 3 }));
442    }
443
444    #[test]
445    fn version_3_project_is_refused() {
446        let book = book_to_json(&book())
447            .unwrap()
448            .replace("\"format_version\": 2", "\"format_version\": 3");
449        for meta_version in [2, 3] {
450            let meta = format!(r#"{{"format_version":{meta_version},"created_with":"x"}}"#);
451            let bytes = zip_of(&[
452                ("meta.json", meta.as_bytes()),
453                ("book.json", book.as_bytes()),
454            ]);
455            assert!(matches!(
456                Project::read(Cursor::new(bytes)),
457                Err(ProjectError::TooNew { found: 3 })
458            ));
459        }
460    }
461
462    #[test]
463    fn version_1_book_saves_as_version_2() {
464        let v1 = book_to_json(&book())
465            .unwrap()
466            .replace("\"format_version\": 2", "\"format_version\": 1");
467        let b = book_from_json(&v1).unwrap();
468        assert_eq!(b.format_version, 2);
469        let again = book_to_json(&b).unwrap();
470        assert!(again.contains("\"format_version\": 2"));
471    }
472
473    #[test]
474    fn migrations_run_in_order_from_the_file_version() {
475        fn v1_to_v2(mut v: Value) -> Result<Value, ProjectError> {
476            v["steps"].as_array_mut().unwrap().push("1→2".into());
477            Ok(v)
478        }
479        fn v2_to_v3(mut v: Value) -> Result<Value, ProjectError> {
480            v["steps"].as_array_mut().unwrap().push("2→3".into());
481            Ok(v)
482        }
483        let steps: &[Migration] = &[v1_to_v2, v2_to_v3];
484        let run = |version: u64| {
485            migrate_with(
486                serde_json::json!({ "format_version": version, "steps": [] }),
487                steps,
488            )
489        };
490        let v = run(1).unwrap();
491        assert_eq!(v["format_version"], 3);
492        assert_eq!(v["steps"], serde_json::json!(["1→2", "2→3"]));
493        assert_eq!(run(2).unwrap()["steps"], serde_json::json!(["2→3"]));
494        assert_eq!(run(3).unwrap()["steps"], serde_json::json!([]));
495        assert!(matches!(run(4), Err(ProjectError::TooNew { found: 4 })));
496        assert!(matches!(run(0), Err(ProjectError::MissingVersion)));
497    }
498
499    #[test]
500    fn bad_versions() {
501        for json in [
502            r#"{}"#,
503            r#"[]"#,
504            r#"{"format_version": "1"}"#,
505            r#"{"format_version": -1}"#,
506        ] {
507            assert!(
508                matches!(book_from_json(json), Err(ProjectError::MissingVersion)),
509                "{json}"
510            );
511        }
512    }
513
514    #[test]
515    fn archive_round_trip() {
516        let p = project();
517        let bytes = to_bytes(&p);
518        let back = Project::read(Cursor::new(&bytes)).unwrap();
519        assert_eq!(back, p);
520        assert_eq!(
521            back.asset("map.png").unwrap().dpi.map(f32::round),
522            Some(300.0)
523        );
524        // Deterministic: same project, same bytes.
525        assert_eq!(to_bytes(&back), bytes);
526    }
527
528    #[test]
529    fn archive_layout() {
530        let bytes = to_bytes(&project());
531        let mut zip = ZipArchive::new(Cursor::new(bytes)).unwrap();
532        let names: Vec<_> = zip.file_names().map(|n| n.unwrap().into_owned()).collect();
533        assert_eq!(
534            names,
535            [
536                "meta.json",
537                "book.json",
538                "assets/map.png",
539                "assets/ornament.svg"
540            ]
541        );
542        let png = zip.by_name("assets/map.png").unwrap();
543        assert_eq!(png.compression(), CompressionMethod::Stored);
544        drop(png);
545        let mut meta = String::new();
546        zip.by_name("meta.json")
547            .unwrap()
548            .read_to_string(&mut meta)
549            .unwrap();
550        let meta: Value = serde_json::from_str(&meta).unwrap();
551        assert_eq!(meta["format_version"], 2);
552        assert_eq!(meta["created_with"], CREATED_WITH);
553    }
554
555    #[test]
556    fn lenient_read() {
557        let book = book_to_json(&book()).unwrap();
558        let png = png_3x2_300dpi();
559        let bytes = zip_of(&[
560            ("book.json", book.as_bytes()),
561            ("assets/.DS_Store", b"junk"),
562            ("assets/sub/x.png", &png),
563            ("__MACOSX/assets/._map.png", b"junk"),
564            ("assets/map.png", &png),
565            ("thumbnails/cover.png", &png),
566        ]);
567        let p = Project::read(Cursor::new(bytes)).unwrap();
568        assert_eq!(p.meta.created_with, "unknown");
569        let ids: Vec<_> = p.assets.iter().map(|a| a.id.as_str()).collect();
570        assert_eq!(ids, ["map.png"]);
571        assert_eq!(p.assets[0].width_px, Some(3));
572    }
573
574    #[test]
575    fn unknown_meta_fields_survive() {
576        let book = book_to_json(&book()).unwrap();
577        let meta = br#"{"format_version":2,"created_with":"kernsetzer 9","modified":"2030-01-01T00:00:00Z","window":{"zoom":2}}"#;
578        let bytes = zip_of(&[("meta.json", meta), ("book.json", book.as_bytes())]);
579        let p = Project::read(Cursor::new(bytes)).unwrap();
580        assert_eq!(p.meta.created_with, "kernsetzer 9");
581        assert_eq!(p.meta.extra["window"]["zoom"], 2);
582        let again = Project::read(Cursor::new(to_bytes(&p))).unwrap();
583        assert_eq!(again.meta, p.meta);
584    }
585
586    #[test]
587    fn read_errors() {
588        assert!(matches!(
589            Project::read(Cursor::new(b"not a zip".to_vec())),
590            Err(ProjectError::Zip(_))
591        ));
592        let bytes = zip_of(&[("assets/map.png", &png_3x2_300dpi())]);
593        assert!(matches!(
594            Project::read(Cursor::new(bytes)),
595            Err(ProjectError::NotAProject)
596        ));
597        let book = book_to_json(&book()).unwrap();
598        let bytes = zip_of(&[
599            ("meta.json", br#"{"format_version":7,"created_with":"x"}"#),
600            ("book.json", book.as_bytes()),
601        ]);
602        assert!(matches!(
603            Project::read(Cursor::new(bytes)),
604            Err(ProjectError::TooNew { found: 7 })
605        ));
606        // Truncated archives fail cleanly.
607        let full = to_bytes(&project());
608        for n in (0..full.len()).step_by(7) {
609            assert!(Project::read(Cursor::new(&full[..n])).is_err(), "{n}");
610        }
611    }
612
613    #[test]
614    fn damaged_archives_never_panic() {
615        let full = to_bytes(&project());
616        for i in 0..full.len() {
617            for mask in [0x01, 0x80, 0xFF] {
618                let mut bytes = full.clone();
619                bytes[i] ^= mask;
620                let _ = Project::read(Cursor::new(bytes));
621            }
622        }
623    }
624
625    #[test]
626    fn write_rejects_bad_ids() {
627        let mut p = project();
628        p.assets.push(Asset::new("../evil.png", png_3x2_300dpi()));
629        assert!(matches!(
630            p.write(Cursor::new(Vec::new())),
631            Err(ProjectError::InvalidAssetId(_))
632        ));
633        let mut p = project();
634        p.assets.push(Asset::new("map.png", png_3x2_300dpi()));
635        assert!(matches!(
636            p.write(Cursor::new(Vec::new())),
637            Err(ProjectError::DuplicateAsset(_))
638        ));
639    }
640
641    #[test]
642    fn save_open_and_autosave() {
643        let dir = std::env::temp_dir().join(format!("setzer-model-{}", std::process::id()));
644        fs::create_dir_all(&dir).unwrap();
645        let path = dir.join("Roman.kernsetzer");
646        let sidecar = autosave_path(&path);
647        assert_eq!(sidecar, dir.join("Roman.kernsetzer.autosave"));
648
649        let mut p = project();
650        p.autosave(&path).unwrap();
651        assert!(sidecar.exists() && !path.exists());
652        assert_eq!(Project::open(&sidecar).unwrap().book, p.book);
653
654        p.save(&path).unwrap();
655        assert!(path.exists() && !sidecar.exists());
656        assert!(!with_suffix(&path, ".saving").exists());
657        let modified = p.meta.modified.clone().unwrap();
658        assert!(
659            modified.ends_with('Z') && modified.len() == 20,
660            "{modified}"
661        );
662        assert_eq!(Project::open(&path).unwrap(), p);
663
664        // Saving again replaces the file; a missing sidecar is fine.
665        p.book.metadata.title = "Zweiter Titel".into();
666        p.save(&path).unwrap();
667        assert_eq!(
668            Project::open(&path).unwrap().book.metadata.title,
669            "Zweiter Titel"
670        );
671        fs::remove_dir_all(&dir).unwrap();
672    }
673
674    #[test]
675    fn timestamps() {
676        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
677        assert_eq!(rfc3339(951_782_400), "2000-02-29T00:00:00Z");
678        assert_eq!(rfc3339(1_792_368_000 + 3_723), "2026-10-19T01:02:03Z");
679        assert_eq!(rfc3339(4_102_444_799), "2099-12-31T23:59:59Z");
680    }
681}