sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
use std::io::{BufRead, Read, Seek, Write};

use super::file_format::{FileFormatReader, FileFormatWriter};
use super::{IoReadError, IoWriteError};
use crate::{Collection, Puzzle};

/// Reader for JSON serialized [`Collection`].
pub struct JsonReader {}

impl JsonReader {
    pub fn new() -> Self { JsonReader {} }
}

impl FileFormatReader for JsonReader {
    fn read<R>(&mut self, src: &mut R) -> Result<Collection, IoReadError>
    where
        R: Read + BufRead + Seek,
    {
        // Reading into a String first rather than handing the reader to `serde_json`: the reader
        // path is documented as the slower one, and `SlcReader` already works this way.
        let mut data = String::new();
        src.read_to_string(&mut data)?;

        serde_json::from_str(&data).map_err(IoReadError::new_json_syntax_error)
    }
}

/// Writer for JSON serialized [`Collection`].
pub struct JsonWriter {}

impl JsonWriter {
    pub fn new() -> Self { JsonWriter {} }
}

impl FileFormatWriter for JsonWriter {
    fn write_collection<W>(&self, collection: &Collection, dest: &mut W) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        Self::write_json(collection, 0, dest)
    }

    /// Writes a bare JSON serialized [`Puzzle`], not a whole [`Collection`] document.
    ///
    /// `puzzle_idx` is used only to attribute errors, same as in the `.sok` writer.
    fn write_puzzle<W>(
        &self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        Self::write_json(puzzle, puzzle_idx, dest)
    }
}

impl JsonWriter {
    fn write_json<W, T>(data: &T, puzzle_idx: usize, dest: &mut W) -> Result<(), IoWriteError>
    where
        W: Write,
        T: serde::Serialize,
    {
        // Serializing these types can only fail through `dest` - they contain no map with non
        // string keys and no floats - so `serde_json::Error` here is always an I/O error, and
        // converting it into one preserves the original `std::io::ErrorKind`.
        serde_json::to_writer_pretty(&mut *dest, data)
            .map_err(|e| IoWriteError::new_io_error(e.into(), puzzle_idx))?;

        // `to_writer_pretty` doesn't terminate the last line.
        dest.write_all(b"\n")
            .map_err(|e| IoWriteError::new_io_error(e, puzzle_idx))
    }
}

#[cfg(test)]
mod tests {
    use std::error::Error;
    use std::fs::File;
    use std::io::{BufReader, Cursor};
    use std::path::PathBuf;

    use indoc::indoc;
    use tempfile::NamedTempFile;

    use super::super::IoReadErrorKind;
    use super::super::sok_file_format::SokReader;
    use super::*;
    use crate::{SolvingMode, Tessellation};

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

    fn collection_from(file_name: &str) -> Result<Collection, Box<dyn Error>> {
        let src_file_path = PathBuf::from(tests_data_dir!()).join(file_name);
        let mut file = BufReader::new(File::open(src_file_path)?);

        if file_name.ends_with(".json") {
            Ok(JsonReader::new().read(&mut file)?)
        } else {
            Ok(SokReader::new(None, false).read(&mut file)?)
        }
    }

    #[test]
    fn it_reads_collection_from_file() -> Result<(), Box<dyn Error>> {
        let collection = collection_from("small_collection.json")?;

        assert_eq!(collection.title, "Mixed collection");
        assert_eq!(collection.author, "ZOMG! Collection author");
        assert_eq!(collection.created_at, "2022-07-01  01:05:06");
        assert_eq!(collection.puzzles.len(), 3);

        let puzzle = &collection.puzzles[0];
        assert_eq!(puzzle.title, "Sokoban Level 1");
        assert_eq!(puzzle.board.tessellation(), Tessellation::Sokoban);
        assert_eq!(puzzle.board.width(), 19);
        assert_eq!(puzzle.saved_games.len(), 3);
        assert_eq!(puzzle.saved_games[2].solving_mode(), SolvingMode::Reverse);

        let sokoban_plus = collection.puzzles[1]
            .sokoban_plus
            .as_ref()
            .ok_or("puzzle 1 should carry Sokoban+")?;
        assert_eq!(sokoban_plus.boxorder(), "1 2 3 4 5");
        assert_eq!(sokoban_plus.goalorder(), "3 4 5 2 1");

        assert_eq!(collection.puzzles[2].board.tessellation(), Tessellation::Hexoban);

        Ok(())
    }

    #[test]
    fn it_writes_collection_to_file() -> Result<(), Box<dyn Error>> {
        let collection1 = collection_from("small_collection.json")?;

        let collection2 = {
            let mut file1 = NamedTempFile::new()?;
            let mut file2 = BufReader::new(file1.reopen()?);

            JsonWriter::new().write_collection(&collection1, &mut file1)?;

            JsonReader::new().read(&mut file2)?
        };

        assert_eq!(collection1, collection2);

        Ok(())
    }

    /// The fixture is generated from its `.sok` twin, so on its own it can only prove that this
    /// module is self consistent. Comparing against the `.sok` reader is what makes it mean
    /// something.
    #[test]
    fn it_agrees_with_the_sok_reader() -> Result<(), Box<dyn Error>> {
        assert_eq!(
            collection_from("small_collection.json")?,
            collection_from("small_collection.sok")?
        );

        Ok(())
    }

    #[test]
    fn when_json_is_malformed_it_reports_the_line() {
        let data = indoc! {r#"
            {
              "title": "Broken",
              "puzzles": [,]
            }
        "#};

        let err = JsonReader::new()
            .read(&mut Cursor::new(data.as_bytes()))
            .expect_err("malformed JSON must be rejected");

        assert!(
            matches!(err.kind(), IoReadErrorKind::JsonSyntaxError { ln } if *ln == 3),
            "{:?}",
            err.kind()
        );
    }

    /// `GameBoard`'s `Deserialize` routes a board syntax error through `serde::de::Error::custom`,
    /// which flattens it into a message - so a bad board is indistinguishable from bad JSON at the
    /// [`IoReadErrorKind`] level, and a caller has to reach for `serde_json`'s own classification.
    #[test]
    fn when_a_board_does_not_parse_it_is_reported_as_a_json_error() -> Result<(), Box<dyn Error>> {
        let data = r##"{"puzzles": [{"board": {"tessellation": "Hexoban", "board": "#-##-#"}}]}"##;

        let err = JsonReader::new()
            .read(&mut Cursor::new(data.as_bytes()))
            .expect_err("an unparsable board must be rejected");

        assert!(matches!(err.kind(), IoReadErrorKind::JsonSyntaxError { .. }));

        let source = err
            .source()
            .and_then(|source| source.downcast_ref::<serde_json::Error>())
            .ok_or("source should be a serde_json error")?;
        assert_eq!(source.classify(), serde_json::error::Category::Data);

        Ok(())
    }

    /// Not a JSON quirk: `.sok` is equally permissive, since a file with no board line in it reads
    /// as a collection of zero puzzles too.
    #[test]
    fn when_json_is_a_foreign_document_it_reads_as_an_empty_collection()
    -> Result<(), Box<dyn Error>> {
        let collection = JsonReader::new().read(&mut Cursor::new(b"{}"))?;

        assert_eq!(collection, Collection::new());

        Ok(())
    }
}