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}