sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
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
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Read, Seek, Write};
use std::path::Path;

#[cfg(feature = "json")]
use super::json_file_format::{JsonReader, JsonWriter};
use super::slc_file_format::{SlcReader, SlcWriter};
use super::sok_file_format::{SokReader, SokWriter};
use super::{IoReadError, IoWriteError, ReaderSettings, WriterSettings};
use crate::{Collection, Puzzle, Tessellation};

/// All supported Sokoban file formats.
///
/// Depending on file format, following restrictions apply:
///
/// | File extension | restrictions                                                                            |
/// | -------------- | --------------------------------------------------------------------------------------- |
/// | `.sok`         | Any [`Tessellation`] puzzles, allows mixing different [`Tessellation`] in the same file |
/// | `.hsb`         | Only [`Hexoban`](Tessellation::Hexoban) puzzles                                         |
/// | `.tsb`         | Only [`Trioban`](Tessellation::Trioban) puzzles                                         |
/// | `.xsb`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
/// | `.txt`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
/// | `.slc`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
#[cfg_attr(
    feature = "json",
    doc = "| `.json`        | Any [`Tessellation`] puzzles, allows mixing different [`Tessellation`] in the same file |"
)]
// An explicit empty doc line, because `cargo +nightly fmt` strips a bare `///` that follows an
// attribute - and without the blank line the paragraph below is swallowed as a table row.
#[doc = ""]
/// If file format restricts [`Tessellation`] of [`Puzzle`](crate::Puzzle) in it, `sokoban_elements`
/// will:
///
/// - refuse to save [`Puzzle`](crate::Puzzle) of different [`Tessellation`] into these files
/// - assume exact [`Tessellation`] for [`Puzzle`](crate::Puzzle) loaded from these files
///
/// If file format doesn't restrict [`Tessellation`] of [`Puzzle`](crate::Puzzle) in it,
/// `sokoban_elements` will allow and implement it too.
///
/// # Todo
///
/// - make writing of metadata optional
/// - make writer for individual Puzzles
///   - with optional metadata
///   - with optional saved games
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
// Required by `Json` being `#[cfg(feature = "json")]`, and not removable while any variant is
// feature-gated. Cargo features are additive across a build graph, so a crate that never enables
// `json` can still be compiled with it on by an unrelated dependency - and an exhaustive `match`
// over this enum in *that* crate would then stop compiling. Matches inside this crate keep their
// exhaustiveness checking regardless.
#[non_exhaustive]
pub enum FileFormat {
    /// XML format associated with [Sokoban for Windows](https://www.sourcecode.se/sokoban/index.php).
    ///
    /// Files typically end with `.slc` extension.
    ///
    /// <div class="warning">
    ///
    /// This is lossy format:
    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
    ///   supported tessellation.
    ///
    /// </div>
    SokobanSLC,

    /// `XSokoban` file format.
    ///
    /// Files typically end with `.xsb` extension.
    ///
    /// <div class="warning">
    ///
    /// This is lossy format:
    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
    ///   supported tessellation!
    ///
    /// </div>
    Xsb,

    /// Same as [`FileFormat::Xsb`], different file extension.
    ///
    /// Files typically end with `.txt` extension.
    ///
    /// <div class="warning">
    ///
    /// This is lossy format:
    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
    ///   supported tessellation!
    ///
    /// </div>
    Txt,

    /// File format base on `Xsb` but for [`Hexoban`](Tessellation::Hexoban)
    /// puzzles.
    ///
    /// Files typically end with `.hsb`
    ///
    /// <div class="warning">
    ///
    /// This is lossy format:
    /// - it doesn't support writing any tessellation except [`Hexoban`](Tessellation::Hexoban)
    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
    ///   not [`Hexoban`](Tessellation::Hexoban), but it silently skips writing unsupported data for
    ///   supported tessellation!
    ///
    /// </div>
    Hsb,

    /// File format base on `Xsb` but for [`Trioban`](Tessellation::Trioban)
    /// puzzles.
    ///
    /// Files typically end with `.tsb`
    ///
    /// <div class="warning">
    ///
    /// This is lossy format:
    /// - it doesn't support writing any tessellation except [`Trioban`](Tessellation::Trioban)
    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
    ///   not [`Trioban`](Tessellation::Trioban), but it silently skips writing unsupported data for
    ///   supported tessellation.
    ///
    /// </div>
    Tsb,

    /// Plain text format defined by [SokobanYASC](https://sourceforge.net/projects/sokobanyasc/).
    ///
    /// Files typically end with `.sok` extension.
    ///
    /// This is most versatile file format because it supports saving complete [`Collection`],
    /// [`Puzzle`](crate::Puzzle) and [`SavedGame`](crate::SavedGame) info along with
    /// [`Sokoban+`](crate::SokobanPlus), differing [`Tessellation`]s in the same file, author
    /// and similar metadata etc..
    ///
    /// ```txt
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// ::         Sokoban (c) by Falcon Co., Ltd., Japan         ::
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// ::                    File Format 0.17                    ::
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// ::                                                        ::
    /// :: File Notes                                  Optional   ::
    /// :: Puzzle 1                                    Required   ::
    /// ::    Title                                    Optional*  ::
    /// ::    Board                                    See legend ::
    /// ::    Puzzle Notes                             Optional   ::
    /// ::    Saved Game or Solution 1                 Optional   ::
    /// ::      Title                                  Optional*  ::
    /// ::      Moves                                  See legend ::
    /// ::      Notes                                  Optional   ::
    /// ::    Saved Game or Solution 2                 Optional   ::
    /// ::    ... (more saved games and solutions)                ::
    /// :: Puzzle 2                                    Optional   ::
    /// :: ... (more puzzles)                                     ::
    /// ::                                                        ::
    /// :: Remarks:                                               ::
    /// ::                                                        ::
    /// :: File Notes                                             ::
    /// ::   File notes consist of unstructured text and          ::
    /// ::   key/value properties, such as "Author: Name". Lines  ::
    /// ::   beginning with "::" are comments meant to be read    ::
    /// ::   only by a person examining the file in a text        ::
    /// ::   editor, and should not be displayed by the Sokoban   ::
    /// ::   program.                                             ::
    /// ::                                                        ::
    /// ::   The optional but recommended property                ::
    /// ::   "Collection: Name" assigns a name to the puzzle      ::
    /// ::   collection. When a collection is copied from the     ::
    /// ::   internet, for example, and pasted into a Sokoban     ::
    /// ::   program, this information allows the collection to   ::
    /// ::   be saved with the proper name.                       ::
    /// ::                                                        ::
    /// :: Titles                                                 ::
    /// ::   A title line is the last non-blank text line before  ::
    /// ::   a board, a saved game, or a solution, provided the   ::
    /// ::   line is preceded  by a blank line or it is the only  ::
    /// ::   text line at this position in the file.              ::
    /// ::                                                        ::
    /// ::   Title lines are optional unless a single or a last   ::
    /// ::   text line from a preceding puzzle, saved game,       ::
    /// ::   solution, or file header can be mistaken for a title ::
    /// ::   line.                                                ::
    /// ::                                                        ::
    /// :: Puzzle Notes                                           ::
    /// ::   Two special key/value pairs are supported in puzzle  ::
    /// ::   notes: "Title" and "Author", hence, titles can       ::
    /// ::   either come from a title line or from a key/value    ::
    /// ::   pair.                                                ::
    /// ::                                                        ::
    /// ::::::::::::::::::::::::::: Board ::::::::::::::::::::::::::
    /// :: Legend.................:      :.................Legend ::
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// :: Wall...................: #  # :...................Wall ::
    /// :: Pusher.................: p  @ :.................Pusher ::
    /// :: Pusher on goal square..: P  + :..Pusher on goal square ::
    /// :: Box....................: b  $ :....................Box ::
    /// :: Box on goal square.....: B  * :.....Box on goal square ::
    /// :: Goal square............: .  . :............Goal square ::
    /// :: Floor..................:      :..................Floor ::
    /// :: Floor..................: -  _ :..................Floor ::
    /// ::                                                        ::
    /// :: Remarks:                                               ::
    /// ::                                                        ::
    /// ::   The first and the last non-empty square in each row  ::
    /// ::   must be a wall or a box on a goal. An empty interior ::
    /// ::   row is written with at least one "-" or "_".         ::
    /// ::                                                        ::
    /// ::   Boards may be run-length encoded (RLE), e.g.,        ::
    /// ::   "###----p.#" may be encoded as "3#4-p.#", and        ::
    /// ::   "#-#-#-##-#-#-#" may be encoded as "2(3(#-)#)".      ::
    /// ::   A row cannot be split over multiple lines.           ::
    /// ::                                                        ::
    /// ::   Rows may be combined on a single line by using "|"   ::
    /// ::   as a row separator, e.g., "--3#|3#-#|#pb.#|5#".      ::
    /// ::   A "|" at the end of a line is optional and may be    ::
    /// ::   omitted.                                             ::
    /// ::                                                        ::
    /// ::::::::::::::::::::::::::: Moves ::::::::::::::::::::::::::
    /// :: Legend.................:      :.................Legend ::
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// :: Move pusher up.........: u  U :.......Push/pull box up ::
    /// :: Move pusher down.......: d  D :.....Push/pull box down ::
    /// :: Move pusher left.......: l  L :.....Push/pull box left ::
    /// :: Move pusher right......: r  R :....Push/pull box right ::
    /// :: Begin jump.............: [  ] :...............End jump ::
    /// :: Begin pusher change....: {  } :......End pusher change ::
    /// :: Current position.......: *  * :.......Current position ::
    /// ::                                                        ::
    /// :: Remarks:                                               ::
    /// ::                                                        ::
    /// ::   Moves may be run-length encoded, e.g., "3r4U" means  ::
    /// ::   "rrrUUUU", and "2(3(dr)R)" means "drdrdrRdrdrdrR".   ::
    /// ::   Each line must, however, have at least one proper    ::
    /// ::   non-digit character. Spaces between moves are        ::
    /// ::   allowed.                                             ::
    /// ::                                                        ::
    /// ::   Jumps and pulls: Only in reverse mode saved games    ::
    /// ::   and solutions.                                       ::
    /// ::                                                        ::
    /// ::   Reverse mode saved games and solutions must begin    ::
    /// ::   with a jump, even if it is empty. An example:        ::
    /// ::   "[]U[rr]d".                                          ::
    /// ::                                                        ::
    /// ::   Pusher changes: Only in puzzles with multiple        ::
    /// ::   pushers, e.g., Multiban. Moves inside the braces     ::
    /// ::   depict the relative movement to get from the         ::
    /// ::   currently active pusher to the next active pusher.   ::
    /// ::   At game start, a "{...}" sequence activates the      ::
    /// ::   pusher relative to the top-left pusher. An example:  ::
    /// ::   "{rddd}Urr{uul}uLU". If the top-left pusher is the   ::
    /// ::   first active pusher, then the empty "{}" can be      ::
    /// ::   omitted.                                             ::
    /// ::                                                        ::
    /// ::   The current position is optional and defaults to the ::
    /// ::   position after the last move.                        ::
    /// ::                                                        ::
    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
    /// ```
    SokobanYASC,

    /// JSON serialized [`Collection`], available with the `json` cargo feature.
    ///
    /// Files typically end with `.json` extension.
    ///
    /// The document is exactly what `serde_json` produces for a [`Collection`] - there is no
    /// envelope and no version field - written pretty printed. Like
    /// [`SokobanYASC`](FileFormat::SokobanYASC) it is lossless and unrestricted: it stores complete
    /// [`Collection`], [`Puzzle`](crate::Puzzle) and [`SavedGame`](crate::SavedGame) data along
    /// with [`Sokoban+`](crate::SokobanPlus), and it allows mixing [`Tessellation`]s in one
    /// file. See the `# Serde` sections of those types for the shape of each.
    ///
    /// <div class="warning">
    ///
    /// Unlike the other formats, this one has no options:
    ///
    /// - [`WriterSettings::print_visible_floor`], [`WriterSettings::rle_encode`] and
    ///   [`WriterSettings::wrap_savegame_lines_at`] are ignored. `print_visible_floor` *has* to be:
    ///   boards are always serialized with visible floors, because trimming them loses board width.
    /// - [`ReaderSettings::legacy_sokoban_plus`] is ignored. `Sokoban+` is stored as typed arrays
    ///   rather than as text, so it has no `99` marker to reinterpret.
    ///
    /// Two reading quirks are worth knowing:
    ///
    /// - A board or saved game that fails to parse is reported as
    ///   [`JsonSyntaxError`](crate::IoReadErrorKind::JsonSyntaxError), not as
    ///   [`BoardSyntaxError`](crate::IoReadErrorKind::BoardSyntaxError), because `serde` flattens
    ///   the underlying error into a message. To tell malformed JSON apart from JSON this crate's
    ///   types rejected, downcast `Error::source` to `serde_json::Error` and inspect its
    ///   `classify()`.
    /// - Every field of [`Collection`] is optional, so any JSON object at all reads as a valid -
    ///   possibly empty - collection. That is not specific to this format: a `.sok` file with no
    ///   board in it also reads as an empty collection.
    ///
    /// </div>
    #[cfg(feature = "json")]
    Json,
}

impl FileFormat {
    const SLC_EXT: &'static str = "slc";
    const TXT_EXT: &'static str = "txt";
    const HSB_EXT: &'static str = "hsb";
    const TSB_EXT: &'static str = "tsb";
    const XSB_EXT: &'static str = "xsb";
    const SOK_EXT: &'static str = "sok";
    #[cfg(feature = "json")]
    const JSON_EXT: &'static str = "json";

    /// File extension for given file format.
    pub fn file_extension(&self) -> &str {
        match self {
            FileFormat::SokobanYASC => FileFormat::SOK_EXT,
            FileFormat::SokobanSLC => FileFormat::SLC_EXT,
            FileFormat::Txt => FileFormat::TXT_EXT,
            FileFormat::Xsb => FileFormat::XSB_EXT,
            FileFormat::Hsb => FileFormat::HSB_EXT,
            FileFormat::Tsb => FileFormat::TSB_EXT,
            #[cfg(feature = "json")]
            FileFormat::Json => FileFormat::JSON_EXT,
        }
    }

    /// [`Some`] if file format restricts allowed [`Tessellation`], otherwise [`None`].
    pub fn restricted_to_tessellation(&self) -> Option<Tessellation> {
        match self {
            FileFormat::SokobanSLC => Some(Tessellation::Sokoban),
            FileFormat::Xsb => Some(Tessellation::Sokoban),
            FileFormat::Txt => Some(Tessellation::Sokoban),
            FileFormat::Tsb => Some(Tessellation::Trioban),
            FileFormat::Hsb => Some(Tessellation::Hexoban),
            FileFormat::SokobanYASC => None,
            #[cfg(feature = "json")]
            FileFormat::Json => None,
        }
    }

    /// [`Some`] if there exists a [`FileFormat`] for file extension from `path`, otherwise
    /// [`None`].
    pub fn from_file_extension(path: impl AsRef<Path>) -> Option<Self> {
        let ext = path.as_ref().extension()?.to_ascii_lowercase();

        match ext.to_str()? {
            Self::SLC_EXT => Some(Self::SokobanSLC),
            Self::XSB_EXT => Some(Self::Xsb),
            Self::TXT_EXT => Some(Self::Txt),
            Self::HSB_EXT => Some(Self::Hsb),
            Self::TSB_EXT => Some(Self::Tsb),
            Self::SOK_EXT => Some(Self::SokobanYASC),
            #[cfg(feature = "json")]
            Self::JSON_EXT => Some(Self::Json),
            _ => None,
        }
    }

    /// Loads [`Collection`].
    ///
    /// ⚠️ Expects source is UTF-8 encoded, but doesn't validate that expectation.
    ///
    /// # Examples
    ///
    /// Reading from file:
    ///
    /// ```rust, no_run
    /// use std::fs::File;
    /// use std::io::BufReader;
    /// use std::path::Path;
    ///
    /// use sokoban_elements::*;
    ///
    /// # fn main() -> Result<(), IoReadError> {
    /// let path = Path::new("Original and Extra.sok");
    /// let mut reader = BufReader::new(File::open(path)?);
    /// let settings = ReaderSettings::default();
    /// let collection = FileFormat::read(&mut reader, settings)?;
    /// #     Ok(())
    /// # }
    /// ```
    ///
    /// Reading from in-memory string:
    ///
    /// ```rust, no_run
    /// use std::io::Cursor;
    ///
    /// use sokoban_elements::*;
    ///
    /// # fn main() -> Result<(), IoReadError> {
    /// let mut settings = ReaderSettings::default();
    /// settings.file_format = FileFormat::Xsb;
    ///
    /// let data: String = "sokoban collection data read from elsewhere...".to_string();
    /// let mut reader = Cursor::new(data.as_bytes());
    ///
    /// let collection = FileFormat::read(&mut reader, settings)?;
    /// let puzzle = &collection.puzzles[0];
    /// #     Ok(())
    /// # }
    /// ```
    pub fn read<R>(src: &mut R, settings: ReaderSettings) -> Result<Collection, IoReadError>
    where
        R: Read + BufRead + Seek,
    {
        let tessellation_hint = settings.file_format.restricted_to_tessellation();

        match settings.file_format {
            FileFormat::SokobanSLC => SlcReader::new().read(src),
            FileFormat::SokobanYASC
            | FileFormat::Xsb
            | FileFormat::Hsb
            | FileFormat::Tsb
            | FileFormat::Txt => {
                SokReader::new(tessellation_hint, settings.legacy_sokoban_plus).read(src)
            }
            #[cfg(feature = "json")]
            FileFormat::Json => JsonReader::new().read(src),
        }
    }

    /// Loads [`Collection`] from file.
    ///
    /// ⚠️ [`ReaderSettings::file_format`] is ignored and is instead constructed from
    /// `src_file_path`.
    pub fn read_from_path(
        src_file_path: impl AsRef<Path>, settings: Option<ReaderSettings>,
    ) -> Result<Collection, IoReadError> {
        if let Some(file_format) = Self::from_file_extension(&src_file_path) {
            let mut reader = BufReader::new(File::open(&src_file_path)?);

            let settings = if let Some(s) = settings {
                ReaderSettings { file_format, ..s }
            } else {
                ReaderSettings {
                    file_format,
                    ..ReaderSettings::default()
                }
            };

            Self::read(&mut reader, settings)
        } else {
            let extension: String = match src_file_path.as_ref().extension() {
                Some(s) => s.to_string_lossy().to_string(),
                None => String::new(),
            };
            Err(IoReadError::new_invalid_extension_error(&extension))
        }
    }

    /// Writes [`Collection`].
    ///
    /// Assumes `dest` is not buffered.
    ///
    /// # Examples
    ///
    /// Write collection to file:
    ///
    /// ```rust, no_run
    /// use std::fs::File;
    /// use std::path::Path;
    ///
    /// use sokoban_elements::*;
    ///
    /// # fn main() -> Result<(), IoWriteError> {
    /// let collection = Collection::new();
    ///
    /// let settings = WriterSettings::default();
    /// let file_with_extension =
    ///     format!("Original and Extra.{}", settings.file_format.file_extension());
    /// let path = Path::new(&file_with_extension);
    /// let mut file = File::create(path)?;
    ///
    /// FileFormat::write(&mut file, &collection, settings)?;
    /// #     Ok(())
    /// # }
    /// ```
    ///
    /// Write collection to in-memory string:
    ///
    /// ```rust, no_run
    /// use std::io::Cursor;
    ///
    /// use sokoban_elements::*;
    ///
    /// # fn main() -> Result<(), IoWriteError> {
    /// let collection = Collection::new();
    ///
    /// let settings = WriterSettings::default();
    /// let mut bytes: Vec<u8> = Vec::new();
    /// let mut writer = Cursor::new(&mut bytes);
    ///
    /// FileFormat::write(&mut writer, &collection, settings)?;
    ///
    /// let collection_text = String::from_utf8(bytes).unwrap();
    /// #     Ok(())
    /// # }
    /// ```
    ///
    /// Write collection to stdout
    ///
    /// ```rust, no_run
    /// use sokoban_elements::*;
    ///
    /// # fn main() -> Result<(), IoWriteError> {
    /// let collection = Collection::new();
    /// let settings = WriterSettings::default();
    /// let mut stdout = std::io::stdout();
    /// FileFormat::write(&mut stdout, &collection, settings)?;
    /// #     Ok(())
    /// # }
    /// ```
    pub fn write<W>(
        dest: &mut W, collection: &Collection, settings: WriterSettings,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        let mut dest = BufWriter::new(dest);

        let restrict_tessellation = settings.file_format.restricted_to_tessellation();

        match settings.file_format {
            FileFormat::SokobanSLC => SlcWriter::new().write_collection(collection, &mut dest),
            FileFormat::SokobanYASC => {
                SokWriter::new(None, false, settings).write_collection(collection, &mut dest)
            }
            FileFormat::Xsb | FileFormat::Hsb | FileFormat::Tsb | FileFormat::Txt => {
                SokWriter::new(restrict_tessellation, true, settings)
                    .write_collection(collection, &mut dest)
            }
            #[cfg(feature = "json")]
            FileFormat::Json => JsonWriter::new().write_collection(collection, &mut dest),
        }?;

        // Everything above wrote into the buffer, so this is where a failing `dest` is found out.
        // Leaving it to `BufWriter::drop()` would discard the error and report success.
        dest.flush().map_err(|e| IoWriteError::new_io_error(e, 0))
    }

    /// Writes [`Collection`] to file.
    ///
    /// ⚠️ [`WriterSettings::file_format`] is ignored and is instead constructed from
    /// `dest_file_path`.
    pub fn save_to_file(
        &self, collection: &Collection, dest_file_path: impl AsRef<Path>,
        settings: Option<WriterSettings>,
    ) -> Result<(), IoWriteError> {
        if let Some(file_format) = FileFormat::from_file_extension(&dest_file_path) {
            // Deliberately unbuffered: `write()` does its own buffering and flushes it. Wrapping
            // here too would add a second buffer that only `Drop` ever empties, discarding any
            // error it hit.
            let mut dest = File::create(&dest_file_path)?;

            let settings = if let Some(s) = settings {
                WriterSettings { file_format, ..s }
            } else {
                WriterSettings {
                    file_format,
                    ..WriterSettings::default()
                }
            };

            FileFormat::write(&mut dest, collection, settings)
        } else {
            let extension: String = match dest_file_path.as_ref().extension() {
                Some(s) => s.to_string_lossy().to_string(),
                None => String::new(),
            };
            Err(IoWriteError::new_invalid_extension_error(&extension))
        }
    }
}

pub(in crate::io) trait FileFormatReader {
    fn read<R>(&mut self, src: &mut R) -> Result<Collection, IoReadError>
    where
        R: Read + BufRead + Seek;
}

pub(in crate::io) trait FileFormatWriter {
    fn write_collection<W>(
        &self, collection: &Collection, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write;

    #[expect(dead_code)]
    fn write_puzzle<W>(
        &self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write;
}

#[cfg(test)]
mod tests {
    use std::io::Cursor;
    use std::path::PathBuf;

    use super::*;

    macro_rules! tests_data_dir {
        () => {
            concat!(env!("CARGO_MANIFEST_DIR"), "/src/io/tests_data/")
        };
    }

    /// Held as a slice rather than pushed under a `#[cfg]` statement, so that the `mut` in the
    /// functions below stays used when the feature is off.
    #[cfg(feature = "json")]
    const JSON_FORMATS: &[FileFormat] = &[FileFormat::Json];
    #[cfg(not(feature = "json"))]
    const JSON_FORMATS: &[FileFormat] = &[];

    fn all_file_formats() -> Vec<FileFormat> {
        let mut retv = vec![
            FileFormat::SokobanYASC,
            FileFormat::SokobanSLC,
            FileFormat::Txt,
            FileFormat::Xsb,
            FileFormat::Hsb,
            FileFormat::Tsb,
        ];
        retv.extend_from_slice(JSON_FORMATS);

        retv
    }

    /// The lossless formats, ie. the ones that can hold a whole [`Collection`].
    fn lossless_file_formats() -> Vec<FileFormat> {
        let mut retv = vec![FileFormat::SokobanYASC];
        retv.extend_from_slice(JSON_FORMATS);

        retv
    }

    fn small_collection() -> Result<Collection, IoReadError> {
        let src_file_path = PathBuf::from(tests_data_dir!()).join("small_collection.sok");

        FileFormat::read_from_path(src_file_path, None)
    }

    /// Guards against a new [`FileFormat`] gaining an extension that
    /// [`from_file_extension()`](FileFormat::from_file_extension()) doesn't know: that lookup fails
    /// open, returning [`None`] rather than failing to compile.
    #[test]
    fn it_maps_every_file_extension_to_its_format() {
        for file_format in all_file_formats() {
            let path = format!("collection.{}", file_format.file_extension());

            assert_eq!(FileFormat::from_file_extension(&path), Some(file_format));
            assert_eq!(
                FileFormat::from_file_extension(path.to_uppercase()),
                Some(file_format)
            );
        }
    }

    #[test]
    fn when_extension_is_unknown_there_is_no_format() {
        assert_eq!(FileFormat::from_file_extension("collection.bin"), None);
        assert_eq!(FileFormat::from_file_extension("collection"), None);
        assert_eq!(FileFormat::from_file_extension(""), None);
    }

    /// Fails every write, but only once something is actually handed to it - which, behind a
    /// [`BufWriter`], is at flush time.
    struct FailingWriter;

    impl Write for FailingWriter {
        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
            Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "no"))
        }

        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
    }

    /// A collection small enough to fit inside [`BufWriter`]'s buffer, so that nothing reaches the
    /// destination until that buffer is emptied.
    fn tiny_collection() -> Result<Collection, crate::BoardSyntaxError> {
        let mut collection = Collection::new();
        collection.puzzles.push(Puzzle {
            board:        crate::GameBoard::parsed("#@$.#", Tessellation::Sokoban)?,
            title:        "Tiny".to_string(),
            author:       String::new(),
            notes:        String::new(),
            saved_games:  Vec::new(),
            sokoban_plus: None,
        });

        Ok(collection)
    }

    /// Writers write into a buffer, so a broken destination is only found out when that buffer is
    /// emptied. Left to `BufWriter::drop()`, the error is discarded and writing reports success.
    #[test]
    fn when_the_destination_fails_writing_reports_it() -> Result<(), Box<dyn std::error::Error>> {
        let collection = tiny_collection()?;

        for file_format in all_file_formats() {
            let result = FileFormat::write(
                &mut FailingWriter,
                &collection,
                WriterSettings {
                    file_format,
                    ..WriterSettings::default()
                },
            );

            assert!(result.is_err(), "{file_format:?} reported success");
        }

        Ok(())
    }

    #[test]
    fn it_round_trips_a_collection_through_write_and_read() -> Result<(), Box<dyn std::error::Error>>
    {
        let collection = small_collection()?;

        for file_format in lossless_file_formats() {
            let mut data: Vec<u8> = Vec::new();
            FileFormat::write(
                &mut data,
                &collection,
                WriterSettings {
                    file_format,
                    ..WriterSettings::default()
                },
            )?;

            let reread = FileFormat::read(
                &mut Cursor::new(data),
                ReaderSettings {
                    file_format,
                    ..ReaderSettings::default()
                },
            )?;

            assert_eq!(reread, collection, "failed for {file_format:?}");
        }

        Ok(())
    }

    #[test]
    fn it_round_trips_a_collection_through_a_file() -> Result<(), Box<dyn std::error::Error>> {
        let collection = small_collection()?;

        for file_format in lossless_file_formats() {
            // The format is taken from the file extension, so a suffix-less temporary file would
            // be rejected as an unknown format.
            let file = tempfile::Builder::new()
                .suffix(&format!(".{}", file_format.file_extension()))
                .tempfile()?;

            file_format.save_to_file(&collection, file.path(), None)?;
            let reread = FileFormat::read_from_path(file.path(), None)?;

            assert_eq!(reread, collection, "failed for {file_format:?}");
        }

        Ok(())
    }
}