sokoban-elements 0.2.1

Parsing and writing Sokoban, Hexoban, Trioban and Octoban file formats.
Documentation
use crate::{GameBoard, SavedGame, SokobanPlus};

/// Sokoban puzzle that consists of [`GameBoard`] and accompanying metadata.
///
/// # Serde
///
/// With the `serde` feature, every field except `board` may be omitted. See
/// [`GameBoard`](crate::GameBoard), [`SavedGame`](crate::SavedGame) and
/// [`SokobanPlus`](crate::SokobanPlus) for their wire formats.
// `deny_unknown_fields` deliberately does *not* belong here, unlike on the three hand-written
// wire-format mirrors (`GameBoardRepr`, `SavedGameRepr`, `SokobanPlusRepr`). `serde` documents it
// as incompatible with `#[serde(flatten)]`, on the flattened field as much as on the outer struct:
// a flattened value is handed every key its container did not claim, so a downstream wrapper adding
// fields of its own beside a flattened `Puzzle` or `Collection` would fail to deserialize. Those
// three mirrors are closed formats this crate fully owns; these two are extension points, as are
// `ReaderSettings` and `WriterSettings` in `src/io/settings.rs`.
//
// Nor may these fields grow a `skip_serializing_if` - see the serde rules in `src/lib.rs`.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Puzzle {
    pub board:        GameBoard,
    #[cfg_attr(feature = "serde", serde(default))]
    pub title:        String,
    #[cfg_attr(feature = "serde", serde(default))]
    pub author:       String,
    #[cfg_attr(feature = "serde", serde(default))]
    pub notes:        String,
    #[cfg_attr(feature = "serde", serde(default))]
    pub sokoban_plus: Option<SokobanPlus>,
    /// ⚠️ Puzzle may pair a Hexoban board with a Sokoban saved game — nothing validates this.
    ///
    /// One thing that could've been done is to make `SavedGame` generic over the tessellation, but
    /// that would have made the code more complex and less ergonomic. Therefore, we document this
    /// and leave it to the user to ensure that the saved games match the board's tessellation.
    ///
    /// [`SavedGame`] and [`GameBoard`] are independent types and callers are responsible for
    /// maintaining relationships between their instances.
    #[cfg_attr(feature = "serde", serde(default))]
    pub saved_games:  Vec<SavedGame>,
}

/// Collection of Sokoban puzzles and accompanying metadata.
#[derive(Debug, Default, Clone, PartialEq)]
#[cfg_attr(
    feature = "serde",
    derive(serde::Serialize, serde::Deserialize),
    serde(default)
)]
pub struct Collection {
    pub title:      String,
    pub author:     String,
    pub created_at: String,
    pub updated_at: String,
    pub email:      String,
    pub url:        String,
    pub notes:      String,
    pub puzzles:    Vec<Puzzle>,
}

impl Collection {
    pub fn new() -> Self {
        Self {
            title:      String::new(),
            author:     String::new(),
            created_at: String::new(),
            updated_at: String::new(),
            email:      String::new(),
            url:        String::new(),
            notes:      String::new(),
            puzzles:    Vec::new(),
        }
    }
}

#[cfg(all(test, feature = "serde"))]
mod tests {
    use std::error::Error;

    use super::*;
    use crate::{BoxID, PlusID, Tessellation};

    fn puzzle() -> Result<Puzzle, Box<dyn Error>> {
        let mut sokoban_plus = SokobanPlus::new();
        sokoban_plus.set_box_plus_id(BoxID::from(0_u8), Some(PlusID::new(1)?));

        Ok(Puzzle {
            board:        GameBoard::parsed("#####|#@$.#|#####", Tessellation::Sokoban)?,
            title:        "Puzzle 1".to_string(),
            author:       "Tomislav".to_string(),
            notes:        "First one".to_string(),
            saved_games:  vec![SavedGame::parsed("[]LuR", Tessellation::Sokoban)?],
            sokoban_plus: Some(sokoban_plus),
        })
    }

    #[test]
    fn it_round_trips_collections_through_json() -> Result<(), Box<dyn Error>> {
        let mut collection = Collection::new();
        collection.title = "Test collection".to_string();
        collection.author = "Tomislav".to_string();
        collection.puzzles = vec![
            puzzle()?,
            Puzzle {
                board:        GameBoard::parsed("---#-#-#-", Tessellation::Hexoban)?,
                title:        String::new(),
                author:       String::new(),
                notes:        String::new(),
                saved_games:  Vec::new(),
                sokoban_plus: None,
            },
        ];

        let json = serde_json::to_string(&collection)?;

        assert_eq!(serde_json::from_str::<Collection>(&json)?, collection);

        Ok(())
    }

    #[test]
    fn when_puzzle_has_only_a_board_the_rest_defaults() -> Result<(), Box<dyn Error>> {
        let puzzle: Puzzle =
            serde_json::from_str(r##"{"board": {"tessellation": "Sokoban", "board": "#@$.#"}}"##)?;

        assert_eq!(puzzle.board.width(), 5);
        assert!(puzzle.title.is_empty());
        assert!(puzzle.saved_games.is_empty());
        assert_eq!(puzzle.sokoban_plus, None);

        Ok(())
    }

    #[test]
    fn when_collection_is_empty_it_round_trips() -> Result<(), Box<dyn Error>> {
        let collection: Collection = serde_json::from_str("{}")?;

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

        Ok(())
    }
}