Skip to main content

setzer_model/
project.rs

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