Skip to main content

sokoban_elements/io/
file_format.rs

1use std::fs::File;
2use std::io::{BufRead, BufReader, BufWriter, Read, Seek, Write};
3use std::path::Path;
4
5#[cfg(feature = "json")]
6use super::json_file_format::{JsonReader, JsonWriter};
7use super::slc_file_format::{SlcReader, SlcWriter};
8use super::sok_file_format::{SokReader, SokWriter};
9use super::{IoReadError, IoWriteError, ReaderSettings, WriterSettings};
10use crate::{Collection, Puzzle, Tessellation};
11
12/// All supported Sokoban file formats.
13///
14/// Depending on file format, following restrictions apply:
15///
16/// | File extension | restrictions                                                                            |
17/// | -------------- | --------------------------------------------------------------------------------------- |
18/// | `.sok`         | Any [`Tessellation`] puzzles, allows mixing different [`Tessellation`] in the same file |
19/// | `.hsb`         | Only [`Hexoban`](Tessellation::Hexoban) puzzles                                         |
20/// | `.tsb`         | Only [`Trioban`](Tessellation::Trioban) puzzles                                         |
21/// | `.xsb`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
22/// | `.txt`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
23/// | `.slc`         | Only [`Sokoban`](Tessellation::Sokoban) puzzles                                         |
24#[cfg_attr(
25    feature = "json",
26    doc = "| `.json`        | Any [`Tessellation`] puzzles, allows mixing different [`Tessellation`] in the same file |"
27)]
28// An explicit empty doc line, because `cargo +nightly fmt` strips a bare `///` that follows an
29// attribute - and without the blank line the paragraph below is swallowed as a table row.
30#[doc = ""]
31/// If file format restricts [`Tessellation`] of [`Puzzle`](crate::Puzzle) in it, `sokoban_elements`
32/// will:
33///
34/// - refuse to save [`Puzzle`](crate::Puzzle) of different [`Tessellation`] into these files
35/// - assume exact [`Tessellation`] for [`Puzzle`](crate::Puzzle) loaded from these files
36///
37/// If file format doesn't restrict [`Tessellation`] of [`Puzzle`](crate::Puzzle) in it,
38/// `sokoban_elements` will allow and implement it too.
39///
40/// # Todo
41///
42/// - make writing of metadata optional
43/// - make writer for individual Puzzles
44///   - with optional metadata
45///   - with optional saved games
46#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48// Required by `Json` being `#[cfg(feature = "json")]`, and not removable while any variant is
49// feature-gated. Cargo features are additive across a build graph, so a crate that never enables
50// `json` can still be compiled with it on by an unrelated dependency - and an exhaustive `match`
51// over this enum in *that* crate would then stop compiling. Matches inside this crate keep their
52// exhaustiveness checking regardless.
53#[non_exhaustive]
54pub enum FileFormat {
55    /// XML format associated with [Sokoban for Windows](https://www.sourcecode.se/sokoban/index.php).
56    ///
57    /// Files typically end with `.slc` extension.
58    ///
59    /// <div class="warning">
60    ///
61    /// This is lossy format:
62    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
63    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
64    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
65    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
66    ///   supported tessellation.
67    ///
68    /// </div>
69    SokobanSLC,
70
71    /// `XSokoban` file format.
72    ///
73    /// Files typically end with `.xsb` extension.
74    ///
75    /// <div class="warning">
76    ///
77    /// This is lossy format:
78    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
79    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
80    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
81    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
82    ///   supported tessellation!
83    ///
84    /// </div>
85    Xsb,
86
87    /// Same as [`FileFormat::Xsb`], different file extension.
88    ///
89    /// Files typically end with `.txt` extension.
90    ///
91    /// <div class="warning">
92    ///
93    /// This is lossy format:
94    /// - it doesn't support writing any tessellation except [`Sokoban`](Tessellation::Sokoban)
95    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
96    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
97    ///   not [`Sokoban`](Tessellation::Sokoban), but it silently skips writing unsupported data for
98    ///   supported tessellation!
99    ///
100    /// </div>
101    Txt,
102
103    /// File format base on `Xsb` but for [`Hexoban`](Tessellation::Hexoban)
104    /// puzzles.
105    ///
106    /// Files typically end with `.hsb`
107    ///
108    /// <div class="warning">
109    ///
110    /// This is lossy format:
111    /// - it doesn't support writing any tessellation except [`Hexoban`](Tessellation::Hexoban)
112    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
113    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
114    ///   not [`Hexoban`](Tessellation::Hexoban), but it silently skips writing unsupported data for
115    ///   supported tessellation!
116    ///
117    /// </div>
118    Hsb,
119
120    /// File format base on `Xsb` but for [`Trioban`](Tessellation::Trioban)
121    /// puzzles.
122    ///
123    /// Files typically end with `.tsb`
124    ///
125    /// <div class="warning">
126    ///
127    /// This is lossy format:
128    /// - it doesn't support writing any tessellation except [`Trioban`](Tessellation::Trioban)
129    /// - it doesn't support writing [`Sokoban+`](crate::SokobanPlus), saved games, notes, etc...
130    /// - [`Collection`] writer reports error if trying to write [`Puzzle`](crate::Puzzle) that is
131    ///   not [`Trioban`](Tessellation::Trioban), but it silently skips writing unsupported data for
132    ///   supported tessellation.
133    ///
134    /// </div>
135    Tsb,
136
137    /// Plain text format defined by [SokobanYASC](https://sourceforge.net/projects/sokobanyasc/).
138    ///
139    /// Files typically end with `.sok` extension.
140    ///
141    /// This is most versatile file format because it supports saving complete [`Collection`],
142    /// [`Puzzle`](crate::Puzzle) and [`SavedGame`](crate::SavedGame) info along with
143    /// [`Sokoban+`](crate::SokobanPlus), differing [`Tessellation`]s in the same file, author
144    /// and similar metadata etc..
145    ///
146    /// ```txt
147    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
148    /// ::         Sokoban (c) by Falcon Co., Ltd., Japan         ::
149    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
150    /// ::                    File Format 0.17                    ::
151    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
152    /// ::                                                        ::
153    /// :: File Notes                                  Optional   ::
154    /// :: Puzzle 1                                    Required   ::
155    /// ::    Title                                    Optional*  ::
156    /// ::    Board                                    See legend ::
157    /// ::    Puzzle Notes                             Optional   ::
158    /// ::    Saved Game or Solution 1                 Optional   ::
159    /// ::      Title                                  Optional*  ::
160    /// ::      Moves                                  See legend ::
161    /// ::      Notes                                  Optional   ::
162    /// ::    Saved Game or Solution 2                 Optional   ::
163    /// ::    ... (more saved games and solutions)                ::
164    /// :: Puzzle 2                                    Optional   ::
165    /// :: ... (more puzzles)                                     ::
166    /// ::                                                        ::
167    /// :: Remarks:                                               ::
168    /// ::                                                        ::
169    /// :: File Notes                                             ::
170    /// ::   File notes consist of unstructured text and          ::
171    /// ::   key/value properties, such as "Author: Name". Lines  ::
172    /// ::   beginning with "::" are comments meant to be read    ::
173    /// ::   only by a person examining the file in a text        ::
174    /// ::   editor, and should not be displayed by the Sokoban   ::
175    /// ::   program.                                             ::
176    /// ::                                                        ::
177    /// ::   The optional but recommended property                ::
178    /// ::   "Collection: Name" assigns a name to the puzzle      ::
179    /// ::   collection. When a collection is copied from the     ::
180    /// ::   internet, for example, and pasted into a Sokoban     ::
181    /// ::   program, this information allows the collection to   ::
182    /// ::   be saved with the proper name.                       ::
183    /// ::                                                        ::
184    /// :: Titles                                                 ::
185    /// ::   A title line is the last non-blank text line before  ::
186    /// ::   a board, a saved game, or a solution, provided the   ::
187    /// ::   line is preceded  by a blank line or it is the only  ::
188    /// ::   text line at this position in the file.              ::
189    /// ::                                                        ::
190    /// ::   Title lines are optional unless a single or a last   ::
191    /// ::   text line from a preceding puzzle, saved game,       ::
192    /// ::   solution, or file header can be mistaken for a title ::
193    /// ::   line.                                                ::
194    /// ::                                                        ::
195    /// :: Puzzle Notes                                           ::
196    /// ::   Two special key/value pairs are supported in puzzle  ::
197    /// ::   notes: "Title" and "Author", hence, titles can       ::
198    /// ::   either come from a title line or from a key/value    ::
199    /// ::   pair.                                                ::
200    /// ::                                                        ::
201    /// ::::::::::::::::::::::::::: Board ::::::::::::::::::::::::::
202    /// :: Legend.................:      :.................Legend ::
203    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
204    /// :: Wall...................: #  # :...................Wall ::
205    /// :: Pusher.................: p  @ :.................Pusher ::
206    /// :: Pusher on goal square..: P  + :..Pusher on goal square ::
207    /// :: Box....................: b  $ :....................Box ::
208    /// :: Box on goal square.....: B  * :.....Box on goal square ::
209    /// :: Goal square............: .  . :............Goal square ::
210    /// :: Floor..................:      :..................Floor ::
211    /// :: Floor..................: -  _ :..................Floor ::
212    /// ::                                                        ::
213    /// :: Remarks:                                               ::
214    /// ::                                                        ::
215    /// ::   The first and the last non-empty square in each row  ::
216    /// ::   must be a wall or a box on a goal. An empty interior ::
217    /// ::   row is written with at least one "-" or "_".         ::
218    /// ::                                                        ::
219    /// ::   Boards may be run-length encoded (RLE), e.g.,        ::
220    /// ::   "###----p.#" may be encoded as "3#4-p.#", and        ::
221    /// ::   "#-#-#-##-#-#-#" may be encoded as "2(3(#-)#)".      ::
222    /// ::   A row cannot be split over multiple lines.           ::
223    /// ::                                                        ::
224    /// ::   Rows may be combined on a single line by using "|"   ::
225    /// ::   as a row separator, e.g., "--3#|3#-#|#pb.#|5#".      ::
226    /// ::   A "|" at the end of a line is optional and may be    ::
227    /// ::   omitted.                                             ::
228    /// ::                                                        ::
229    /// ::::::::::::::::::::::::::: Moves ::::::::::::::::::::::::::
230    /// :: Legend.................:      :.................Legend ::
231    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
232    /// :: Move pusher up.........: u  U :.......Push/pull box up ::
233    /// :: Move pusher down.......: d  D :.....Push/pull box down ::
234    /// :: Move pusher left.......: l  L :.....Push/pull box left ::
235    /// :: Move pusher right......: r  R :....Push/pull box right ::
236    /// :: Begin jump.............: [  ] :...............End jump ::
237    /// :: Begin pusher change....: {  } :......End pusher change ::
238    /// :: Current position.......: *  * :.......Current position ::
239    /// ::                                                        ::
240    /// :: Remarks:                                               ::
241    /// ::                                                        ::
242    /// ::   Moves may be run-length encoded, e.g., "3r4U" means  ::
243    /// ::   "rrrUUUU", and "2(3(dr)R)" means "drdrdrRdrdrdrR".   ::
244    /// ::   Each line must, however, have at least one proper    ::
245    /// ::   non-digit character. Spaces between moves are        ::
246    /// ::   allowed.                                             ::
247    /// ::                                                        ::
248    /// ::   Jumps and pulls: Only in reverse mode saved games    ::
249    /// ::   and solutions.                                       ::
250    /// ::                                                        ::
251    /// ::   Reverse mode saved games and solutions must begin    ::
252    /// ::   with a jump, even if it is empty. An example:        ::
253    /// ::   "[]U[rr]d".                                          ::
254    /// ::                                                        ::
255    /// ::   Pusher changes: Only in puzzles with multiple        ::
256    /// ::   pushers, e.g., Multiban. Moves inside the braces     ::
257    /// ::   depict the relative movement to get from the         ::
258    /// ::   currently active pusher to the next active pusher.   ::
259    /// ::   At game start, a "{...}" sequence activates the      ::
260    /// ::   pusher relative to the top-left pusher. An example:  ::
261    /// ::   "{rddd}Urr{uul}uLU". If the top-left pusher is the   ::
262    /// ::   first active pusher, then the empty "{}" can be      ::
263    /// ::   omitted.                                             ::
264    /// ::                                                        ::
265    /// ::   The current position is optional and defaults to the ::
266    /// ::   position after the last move.                        ::
267    /// ::                                                        ::
268    /// ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
269    /// ```
270    SokobanYASC,
271
272    /// JSON serialized [`Collection`], available with the `json` cargo feature.
273    ///
274    /// Files typically end with `.json` extension.
275    ///
276    /// The document is exactly what `serde_json` produces for a [`Collection`] - there is no
277    /// envelope and no version field - written pretty printed. Like
278    /// [`SokobanYASC`](FileFormat::SokobanYASC) it is lossless and unrestricted: it stores complete
279    /// [`Collection`], [`Puzzle`](crate::Puzzle) and [`SavedGame`](crate::SavedGame) data along
280    /// with [`Sokoban+`](crate::SokobanPlus), and it allows mixing [`Tessellation`]s in one
281    /// file. See the `# Serde` sections of those types for the shape of each.
282    ///
283    /// <div class="warning">
284    ///
285    /// Unlike the other formats, this one has no options:
286    ///
287    /// - [`WriterSettings::print_visible_floor`], [`WriterSettings::rle_encode`] and
288    ///   [`WriterSettings::wrap_savegame_lines_at`] are ignored. `print_visible_floor` *has* to be:
289    ///   boards are always serialized with visible floors, because trimming them loses board width.
290    /// - [`ReaderSettings::legacy_sokoban_plus`] is ignored. `Sokoban+` is stored as typed arrays
291    ///   rather than as text, so it has no `99` marker to reinterpret.
292    ///
293    /// Two reading quirks are worth knowing:
294    ///
295    /// - A board or saved game that fails to parse is reported as
296    ///   [`JsonSyntaxError`](crate::IoReadErrorKind::JsonSyntaxError), not as
297    ///   [`BoardSyntaxError`](crate::IoReadErrorKind::BoardSyntaxError), because `serde` flattens
298    ///   the underlying error into a message. To tell malformed JSON apart from JSON this crate's
299    ///   types rejected, downcast `Error::source` to `serde_json::Error` and inspect its
300    ///   `classify()`.
301    /// - Every field of [`Collection`] is optional, so any JSON object at all reads as a valid -
302    ///   possibly empty - collection. That is not specific to this format: a `.sok` file with no
303    ///   board in it also reads as an empty collection.
304    ///
305    /// </div>
306    #[cfg(feature = "json")]
307    Json,
308}
309
310impl FileFormat {
311    const SLC_EXT: &'static str = "slc";
312    const TXT_EXT: &'static str = "txt";
313    const HSB_EXT: &'static str = "hsb";
314    const TSB_EXT: &'static str = "tsb";
315    const XSB_EXT: &'static str = "xsb";
316    const SOK_EXT: &'static str = "sok";
317    #[cfg(feature = "json")]
318    const JSON_EXT: &'static str = "json";
319
320    /// File extension for given file format.
321    pub fn file_extension(&self) -> &str {
322        match self {
323            FileFormat::SokobanYASC => FileFormat::SOK_EXT,
324            FileFormat::SokobanSLC => FileFormat::SLC_EXT,
325            FileFormat::Txt => FileFormat::TXT_EXT,
326            FileFormat::Xsb => FileFormat::XSB_EXT,
327            FileFormat::Hsb => FileFormat::HSB_EXT,
328            FileFormat::Tsb => FileFormat::TSB_EXT,
329            #[cfg(feature = "json")]
330            FileFormat::Json => FileFormat::JSON_EXT,
331        }
332    }
333
334    /// [`Some`] if file format restricts allowed [`Tessellation`], otherwise [`None`].
335    pub fn restricted_to_tessellation(&self) -> Option<Tessellation> {
336        match self {
337            FileFormat::SokobanSLC => Some(Tessellation::Sokoban),
338            FileFormat::Xsb => Some(Tessellation::Sokoban),
339            FileFormat::Txt => Some(Tessellation::Sokoban),
340            FileFormat::Tsb => Some(Tessellation::Trioban),
341            FileFormat::Hsb => Some(Tessellation::Hexoban),
342            FileFormat::SokobanYASC => None,
343            #[cfg(feature = "json")]
344            FileFormat::Json => None,
345        }
346    }
347
348    /// [`Some`] if there exists a [`FileFormat`] for file extension from `path`, otherwise
349    /// [`None`].
350    pub fn from_file_extension(path: impl AsRef<Path>) -> Option<Self> {
351        let ext = path.as_ref().extension()?.to_ascii_lowercase();
352
353        match ext.to_str()? {
354            Self::SLC_EXT => Some(Self::SokobanSLC),
355            Self::XSB_EXT => Some(Self::Xsb),
356            Self::TXT_EXT => Some(Self::Txt),
357            Self::HSB_EXT => Some(Self::Hsb),
358            Self::TSB_EXT => Some(Self::Tsb),
359            Self::SOK_EXT => Some(Self::SokobanYASC),
360            #[cfg(feature = "json")]
361            Self::JSON_EXT => Some(Self::Json),
362            _ => None,
363        }
364    }
365
366    /// Loads [`Collection`].
367    ///
368    /// ⚠️ Expects source is UTF-8 encoded, but doesn't validate that expectation.
369    ///
370    /// # Examples
371    ///
372    /// Reading from file:
373    ///
374    /// ```rust, no_run
375    /// use std::fs::File;
376    /// use std::io::BufReader;
377    /// use std::path::Path;
378    ///
379    /// use sokoban_elements::*;
380    ///
381    /// # fn main() -> Result<(), IoReadError> {
382    /// let path = Path::new("Original and Extra.sok");
383    /// let mut reader = BufReader::new(File::open(path)?);
384    /// let settings = ReaderSettings::default();
385    /// let collection = FileFormat::read(&mut reader, settings)?;
386    /// #     Ok(())
387    /// # }
388    /// ```
389    ///
390    /// Reading from in-memory string:
391    ///
392    /// ```rust, no_run
393    /// use std::io::Cursor;
394    ///
395    /// use sokoban_elements::*;
396    ///
397    /// # fn main() -> Result<(), IoReadError> {
398    /// let mut settings = ReaderSettings::default();
399    /// settings.file_format = FileFormat::Xsb;
400    ///
401    /// let data: String = "sokoban collection data read from elsewhere...".to_string();
402    /// let mut reader = Cursor::new(data.as_bytes());
403    ///
404    /// let collection = FileFormat::read(&mut reader, settings)?;
405    /// let puzzle = &collection.puzzles[0];
406    /// #     Ok(())
407    /// # }
408    /// ```
409    pub fn read<R>(src: &mut R, settings: ReaderSettings) -> Result<Collection, IoReadError>
410    where
411        R: Read + BufRead + Seek,
412    {
413        let tessellation_hint = settings.file_format.restricted_to_tessellation();
414
415        match settings.file_format {
416            FileFormat::SokobanSLC => SlcReader::new().read(src),
417            FileFormat::SokobanYASC
418            | FileFormat::Xsb
419            | FileFormat::Hsb
420            | FileFormat::Tsb
421            | FileFormat::Txt => {
422                SokReader::new(tessellation_hint, settings.legacy_sokoban_plus).read(src)
423            }
424            #[cfg(feature = "json")]
425            FileFormat::Json => JsonReader::new().read(src),
426        }
427    }
428
429    /// Loads [`Collection`] from file.
430    ///
431    /// ⚠️ [`ReaderSettings::file_format`] is ignored and is instead constructed from
432    /// `src_file_path`.
433    pub fn read_from_path(
434        src_file_path: impl AsRef<Path>, settings: Option<ReaderSettings>,
435    ) -> Result<Collection, IoReadError> {
436        if let Some(file_format) = Self::from_file_extension(&src_file_path) {
437            let mut reader = BufReader::new(File::open(&src_file_path)?);
438
439            let settings = if let Some(s) = settings {
440                ReaderSettings { file_format, ..s }
441            } else {
442                ReaderSettings {
443                    file_format,
444                    ..ReaderSettings::default()
445                }
446            };
447
448            Self::read(&mut reader, settings)
449        } else {
450            let extension: String = match src_file_path.as_ref().extension() {
451                Some(s) => s.to_string_lossy().to_string(),
452                None => String::new(),
453            };
454            Err(IoReadError::new_invalid_extension_error(&extension))
455        }
456    }
457
458    /// Writes [`Collection`].
459    ///
460    /// Assumes `dest` is not buffered.
461    ///
462    /// # Examples
463    ///
464    /// Write collection to file:
465    ///
466    /// ```rust, no_run
467    /// use std::fs::File;
468    /// use std::path::Path;
469    ///
470    /// use sokoban_elements::*;
471    ///
472    /// # fn main() -> Result<(), IoWriteError> {
473    /// let collection = Collection::new();
474    ///
475    /// let settings = WriterSettings::default();
476    /// let file_with_extension =
477    ///     format!("Original and Extra.{}", settings.file_format.file_extension());
478    /// let path = Path::new(&file_with_extension);
479    /// let mut file = File::create(path)?;
480    ///
481    /// FileFormat::write(&mut file, &collection, settings)?;
482    /// #     Ok(())
483    /// # }
484    /// ```
485    ///
486    /// Write collection to in-memory string:
487    ///
488    /// ```rust, no_run
489    /// use std::io::Cursor;
490    ///
491    /// use sokoban_elements::*;
492    ///
493    /// # fn main() -> Result<(), IoWriteError> {
494    /// let collection = Collection::new();
495    ///
496    /// let settings = WriterSettings::default();
497    /// let mut bytes: Vec<u8> = Vec::new();
498    /// let mut writer = Cursor::new(&mut bytes);
499    ///
500    /// FileFormat::write(&mut writer, &collection, settings)?;
501    ///
502    /// let collection_text = String::from_utf8(bytes).unwrap();
503    /// #     Ok(())
504    /// # }
505    /// ```
506    ///
507    /// Write collection to stdout
508    ///
509    /// ```rust, no_run
510    /// use sokoban_elements::*;
511    ///
512    /// # fn main() -> Result<(), IoWriteError> {
513    /// let collection = Collection::new();
514    /// let settings = WriterSettings::default();
515    /// let mut stdout = std::io::stdout();
516    /// FileFormat::write(&mut stdout, &collection, settings)?;
517    /// #     Ok(())
518    /// # }
519    /// ```
520    pub fn write<W>(
521        dest: &mut W, collection: &Collection, settings: WriterSettings,
522    ) -> Result<(), IoWriteError>
523    where
524        W: Write,
525    {
526        let mut dest = BufWriter::new(dest);
527
528        let restrict_tessellation = settings.file_format.restricted_to_tessellation();
529
530        match settings.file_format {
531            FileFormat::SokobanSLC => SlcWriter::new().write_collection(collection, &mut dest),
532            FileFormat::SokobanYASC => {
533                SokWriter::new(None, false, settings).write_collection(collection, &mut dest)
534            }
535            FileFormat::Xsb | FileFormat::Hsb | FileFormat::Tsb | FileFormat::Txt => {
536                SokWriter::new(restrict_tessellation, true, settings)
537                    .write_collection(collection, &mut dest)
538            }
539            #[cfg(feature = "json")]
540            FileFormat::Json => JsonWriter::new().write_collection(collection, &mut dest),
541        }?;
542
543        // Everything above wrote into the buffer, so this is where a failing `dest` is found out.
544        // Leaving it to `BufWriter::drop()` would discard the error and report success.
545        dest.flush().map_err(|e| IoWriteError::new_io_error(e, 0))
546    }
547
548    /// Writes [`Collection`] to file.
549    ///
550    /// ⚠️ [`WriterSettings::file_format`] is ignored and is instead constructed from
551    /// `dest_file_path`.
552    pub fn save_to_file(
553        &self, collection: &Collection, dest_file_path: impl AsRef<Path>,
554        settings: Option<WriterSettings>,
555    ) -> Result<(), IoWriteError> {
556        if let Some(file_format) = FileFormat::from_file_extension(&dest_file_path) {
557            // Deliberately unbuffered: `write()` does its own buffering and flushes it. Wrapping
558            // here too would add a second buffer that only `Drop` ever empties, discarding any
559            // error it hit.
560            let mut dest = File::create(&dest_file_path)?;
561
562            let settings = if let Some(s) = settings {
563                WriterSettings { file_format, ..s }
564            } else {
565                WriterSettings {
566                    file_format,
567                    ..WriterSettings::default()
568                }
569            };
570
571            FileFormat::write(&mut dest, collection, settings)
572        } else {
573            let extension: String = match dest_file_path.as_ref().extension() {
574                Some(s) => s.to_string_lossy().to_string(),
575                None => String::new(),
576            };
577            Err(IoWriteError::new_invalid_extension_error(&extension))
578        }
579    }
580}
581
582pub(in crate::io) trait FileFormatReader {
583    fn read<R>(&mut self, src: &mut R) -> Result<Collection, IoReadError>
584    where
585        R: Read + BufRead + Seek;
586}
587
588pub(in crate::io) trait FileFormatWriter {
589    fn write_collection<W>(
590        &self, collection: &Collection, dest: &mut W,
591    ) -> Result<(), IoWriteError>
592    where
593        W: Write;
594
595    #[expect(dead_code)]
596    fn write_puzzle<W>(
597        &self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
598    ) -> Result<(), IoWriteError>
599    where
600        W: Write;
601}
602
603#[cfg(test)]
604mod tests {
605    use std::io::Cursor;
606    use std::path::PathBuf;
607
608    use super::*;
609
610    macro_rules! tests_data_dir {
611        () => {
612            concat!(env!("CARGO_MANIFEST_DIR"), "/src/io/tests_data/")
613        };
614    }
615
616    /// Held as a slice rather than pushed under a `#[cfg]` statement, so that the `mut` in the
617    /// functions below stays used when the feature is off.
618    #[cfg(feature = "json")]
619    const JSON_FORMATS: &[FileFormat] = &[FileFormat::Json];
620    #[cfg(not(feature = "json"))]
621    const JSON_FORMATS: &[FileFormat] = &[];
622
623    fn all_file_formats() -> Vec<FileFormat> {
624        let mut retv = vec![
625            FileFormat::SokobanYASC,
626            FileFormat::SokobanSLC,
627            FileFormat::Txt,
628            FileFormat::Xsb,
629            FileFormat::Hsb,
630            FileFormat::Tsb,
631        ];
632        retv.extend_from_slice(JSON_FORMATS);
633
634        retv
635    }
636
637    /// The lossless formats, ie. the ones that can hold a whole [`Collection`].
638    fn lossless_file_formats() -> Vec<FileFormat> {
639        let mut retv = vec![FileFormat::SokobanYASC];
640        retv.extend_from_slice(JSON_FORMATS);
641
642        retv
643    }
644
645    fn small_collection() -> Result<Collection, IoReadError> {
646        let src_file_path = PathBuf::from(tests_data_dir!()).join("small_collection.sok");
647
648        FileFormat::read_from_path(src_file_path, None)
649    }
650
651    /// Guards against a new [`FileFormat`] gaining an extension that
652    /// [`from_file_extension()`](FileFormat::from_file_extension()) doesn't know: that lookup fails
653    /// open, returning [`None`] rather than failing to compile.
654    #[test]
655    fn it_maps_every_file_extension_to_its_format() {
656        for file_format in all_file_formats() {
657            let path = format!("collection.{}", file_format.file_extension());
658
659            assert_eq!(FileFormat::from_file_extension(&path), Some(file_format));
660            assert_eq!(
661                FileFormat::from_file_extension(path.to_uppercase()),
662                Some(file_format)
663            );
664        }
665    }
666
667    #[test]
668    fn when_extension_is_unknown_there_is_no_format() {
669        assert_eq!(FileFormat::from_file_extension("collection.bin"), None);
670        assert_eq!(FileFormat::from_file_extension("collection"), None);
671        assert_eq!(FileFormat::from_file_extension(""), None);
672    }
673
674    /// Fails every write, but only once something is actually handed to it - which, behind a
675    /// [`BufWriter`], is at flush time.
676    struct FailingWriter;
677
678    impl Write for FailingWriter {
679        fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
680            Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "no"))
681        }
682
683        fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
684    }
685
686    /// A collection small enough to fit inside [`BufWriter`]'s buffer, so that nothing reaches the
687    /// destination until that buffer is emptied.
688    fn tiny_collection() -> Result<Collection, crate::BoardSyntaxError> {
689        let mut collection = Collection::new();
690        collection.puzzles.push(Puzzle {
691            board:        crate::GameBoard::parsed("#@$.#", Tessellation::Sokoban)?,
692            title:        "Tiny".to_string(),
693            author:       String::new(),
694            notes:        String::new(),
695            saved_games:  Vec::new(),
696            sokoban_plus: None,
697        });
698
699        Ok(collection)
700    }
701
702    /// Writers write into a buffer, so a broken destination is only found out when that buffer is
703    /// emptied. Left to `BufWriter::drop()`, the error is discarded and writing reports success.
704    #[test]
705    fn when_the_destination_fails_writing_reports_it() -> Result<(), Box<dyn std::error::Error>> {
706        let collection = tiny_collection()?;
707
708        for file_format in all_file_formats() {
709            let result = FileFormat::write(
710                &mut FailingWriter,
711                &collection,
712                WriterSettings {
713                    file_format,
714                    ..WriterSettings::default()
715                },
716            );
717
718            assert!(result.is_err(), "{file_format:?} reported success");
719        }
720
721        Ok(())
722    }
723
724    #[test]
725    fn it_round_trips_a_collection_through_write_and_read() -> Result<(), Box<dyn std::error::Error>>
726    {
727        let collection = small_collection()?;
728
729        for file_format in lossless_file_formats() {
730            let mut data: Vec<u8> = Vec::new();
731            FileFormat::write(
732                &mut data,
733                &collection,
734                WriterSettings {
735                    file_format,
736                    ..WriterSettings::default()
737                },
738            )?;
739
740            let reread = FileFormat::read(
741                &mut Cursor::new(data),
742                ReaderSettings {
743                    file_format,
744                    ..ReaderSettings::default()
745                },
746            )?;
747
748            assert_eq!(reread, collection, "failed for {file_format:?}");
749        }
750
751        Ok(())
752    }
753
754    #[test]
755    fn it_round_trips_a_collection_through_a_file() -> Result<(), Box<dyn std::error::Error>> {
756        let collection = small_collection()?;
757
758        for file_format in lossless_file_formats() {
759            // The format is taken from the file extension, so a suffix-less temporary file would
760            // be rejected as an unknown format.
761            let file = tempfile::Builder::new()
762                .suffix(&format!(".{}", file_format.file_extension()))
763                .tempfile()?;
764
765            file_format.save_to_file(&collection, file.path(), None)?;
766            let reread = FileFormat::read_from_path(file.path(), None)?;
767
768            assert_eq!(reread, collection, "failed for {file_format:?}");
769        }
770
771        Ok(())
772    }
773}