sokoban-elements 0.2.1

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

use itertools::Itertools;
use roxmltree::Document;
use simple_xml_builder::XMLElement;

use super::file_format::{FileFormatReader, FileFormatWriter};
use super::{IoReadError, IoWriteError};
use crate::utilities::is_blank;
use crate::{Collection, GameBoard, Puzzle, Tessellation};

const TAG_ROOT: &str = "SokobanLevels";
const TAG_TITLE: &str = "Title";
const TAG_DESCRIPTION: &str = "Description";
const TAG_EMAIL: &str = "Email";
const TAG_URL: &str = "Url";
const TAG_COLLECTION: &str = "LevelCollection";
const ATTR_COPYRIGHT: &str = "Copyright";
const ATTR_MAX_WIDTH: &str = "MaxWidth";
const ATTR_MAX_HEIGHT: &str = "MaxHeight";
const TAG_LEVEL: &str = "Level";
const ATTR_ID: &str = "Id";
const ATTR_WIDTH: &str = "Width";
const ATTR_HEIGHT: &str = "Height";
const TAG_LINE: &str = "L";

pub struct SlcReader {}

impl FileFormatReader for SlcReader {
    fn read<R>(&mut self, src: &mut R) -> Result<Collection, IoReadError>
    where
        R: Read + BufRead + Seek,
    {
        let mut data = String::new();
        match src.read_to_string(&mut data) {
            Ok(_) => Self::parse(&data),
            Err(e) => Err(e.into()),
        }
    }
}

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

    fn parse(data: &str) -> Result<Collection, IoReadError> {
        #[expect(clippy::as_conversions)]
        let document = match Document::parse(data) {
            Ok(doc) => doc,
            Err(e) => {
                return Err(IoReadError::new_xml_syntax_error(
                    e.pos().row as usize,
                    Some(Box::new(e)),
                    String::new(),
                ));
            }
        };

        let root_name = document.root_element().tag_name().name();
        if root_name != TAG_ROOT {
            return Err(IoReadError::new_xml_syntax_error(
                0,
                None,
                format!("invalid XML root element, expected \"{TAG_ROOT}\", got \"{root_name}\"!"),
            ));
        }

        let mut collection = Collection::new();

        for child in document.root_element().children() {
            let tag_name = child.tag_name().name();

            if tag_name == TAG_TITLE {
                if let Some(t) = child.text() {
                    collection.title = t.to_string();
                }
            } else if tag_name == TAG_DESCRIPTION {
                if let Some(t) = child.text() {
                    collection.notes = t.to_string();
                }
            } else if tag_name == TAG_URL {
                if let Some(t) = child.text() {
                    collection.url = t.to_string();
                }
            } else if tag_name == TAG_EMAIL {
                if let Some(t) = child.text() {
                    collection.email = t.to_string();
                }
            } else if tag_name == TAG_COLLECTION {
                if let Some(t) = child.attribute(ATTR_COPYRIGHT) {
                    collection.author = t.to_string();
                }

                for level_node in child.children() {
                    if level_node.tag_name().name() == TAG_LEVEL {
                        let board: String = level_node
                            .children()
                            .filter(|l| l.tag_name().name() == TAG_LINE && l.text().is_some())
                            .filter_map(|l| l.text())
                            .join("\n");

                        let board =
                            GameBoard::parsed(&board, Tessellation::Sokoban).map_err(|e| {
                                IoReadError::new_puzzle_syntax_error(level_node.range().start, e)
                            })?;

                        let puzzle = Puzzle {
                            board,
                            title: match level_node.attribute(ATTR_ID) {
                                Some(t) => t.to_string(),
                                None => String::new(),
                            },
                            author: if let Some(t) = level_node.attribute(ATTR_COPYRIGHT) {
                                t.to_string()
                            } else {
                                collection.author.clone()
                            },
                            notes: String::default(),
                            saved_games: Vec::default(),
                            sokoban_plus: None,
                        };

                        collection.puzzles.push(puzzle);
                    }
                }
            }
        }

        Ok(collection)
    }
}

pub struct SlcWriter {}

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

    fn puzzle_writer(puzzle: &Puzzle, write_author: bool, dest: &mut XMLElement) {
        let mut level = XMLElement::new(TAG_LEVEL);
        level.add_attribute(ATTR_ID, &puzzle.title);
        level.add_attribute(ATTR_WIDTH, puzzle.board.width());
        level.add_attribute(ATTR_HEIGHT, puzzle.board.height());

        if write_author {
            level.add_attribute(ATTR_COPYRIGHT, &puzzle.author);
        }

        for lvl_line in puzzle.board.printed(false).lines() {
            let mut l = XMLElement::new(TAG_LINE);
            l.add_text(lvl_line);
            level.add_child(l);
        }
        dest.add_child(level);
    }

    fn doc_start() -> XMLElement {
        let mut doc = XMLElement::new(TAG_ROOT);
        doc.add_attribute("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance");
        doc.add_attribute("xsi:schemaLocation", "SokobanLev.xsd");
        doc
    }
}

impl FileFormatWriter for SlcWriter {
    fn write_puzzle<W>(
        &self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
    ) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        let mut doc = Self::doc_start();

        let mut col = XMLElement::new(TAG_COLLECTION);

        col.add_attribute(ATTR_MAX_WIDTH, puzzle.board.width());
        col.add_attribute(ATTR_MAX_HEIGHT, puzzle.board.height());

        Self::puzzle_writer(puzzle, true, &mut col);

        doc.add_child(col);

        match doc.write(dest) {
            Ok(_) => Ok(()),
            Err(e) => Err(IoWriteError::new_io_error(e, puzzle_idx)),
        }
    }

    fn write_collection<W>(&self, collection: &Collection, dest: &mut W) -> Result<(), IoWriteError>
    where
        W: Write,
    {
        let mut doc = Self::doc_start();

        if !is_blank(&collection.title) {
            let mut t = XMLElement::new(TAG_TITLE);
            t.add_text(&collection.title);
            doc.add_child(t);
        }

        if !is_blank(&collection.url) {
            let mut t = XMLElement::new(TAG_URL);
            t.add_text(&collection.url);
            doc.add_child(t);
        }

        if !is_blank(&collection.email) {
            let mut t = XMLElement::new(TAG_EMAIL);
            t.add_text(&collection.email);
            doc.add_child(t);
        }

        if !is_blank(&collection.notes) {
            let mut t = XMLElement::new(TAG_DESCRIPTION);
            t.add_text(&collection.notes);
            doc.add_child(t);
        }

        let mut col = XMLElement::new(TAG_COLLECTION);
        if !is_blank(&collection.author) {
            col.add_attribute(ATTR_COPYRIGHT, &collection.author);
        }
        col.add_attribute(
            ATTR_MAX_WIDTH,
            collection
                .puzzles
                .iter()
                .map(|p| p.board.width())
                .max()
                .unwrap_or(0),
        );
        col.add_attribute(
            ATTR_MAX_HEIGHT,
            collection
                .puzzles
                .iter()
                .map(|p| p.board.height())
                .max()
                .unwrap_or(0),
        );

        for puzzle in &collection.puzzles {
            if puzzle.board.tessellation() == Tessellation::Sokoban {
                Self::puzzle_writer(
                    puzzle,
                    !is_blank(&puzzle.author) && puzzle.author != collection.author,
                    &mut col,
                );
            }
        }

        doc.add_child(col);

        match doc.write(dest) {
            Ok(_) => Ok(()),
            Err(e) => Err(IoWriteError::new_io_error(e, 0)),
        }
    }
}

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

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

    use super::*;

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

    #[test]
    fn it_reads_collection_from_file() -> Result<(), IoReadError> {
        let collection = {
            let data_dir = PathBuf::from(tests_data_dir!());
            let src_file_path = data_dir.join("Green_Machine.slc");
            let mut file = BufReader::new(File::open(src_file_path)?);
            SlcReader::new().read(&mut file)?
        };

        assert_eq!(collection.title, "Green Machine!");
        assert_eq!(collection.puzzles.len(), 30);
        assert_eq!(collection.puzzles[1].title, "Free-Speech");
        assert_eq!(collection.puzzles[1].board.width(), 18);
        assert_eq!(collection.puzzles[1].board.height(), 16);

        let board = indoc! {"---------####------
                             ---------#--######-
                             ##########-$##---#-
                             #--------------$-##
                             #-####-####$$#-#--#
                             #-$---...--....$$-#
                             ####-#--.......$--#
                             ---#-##-#-#-###--##
                             ---#-##$#-#-#-#$##-
                             --##----#-#$###-#--
                             --#-$####-#--##-#--
                             --#-------#---$-#--
                             --#####---#--##-#--
                             ------###$#####-#--
                             --------#@------#--
                             --------#########--"};

        assert_eq!(collection.puzzles[2].board.printed(true), board);

        Ok(())
    }

    #[test]
    fn it_writes_collection_to_file() -> Result<(), IoWriteError> {
        let collection1 = {
            let data_dir = PathBuf::from(tests_data_dir!());
            let src_file_path = data_dir.join("Green_Machine.slc");
            let mut file = BufReader::new(File::open(src_file_path)?);
            match SlcReader::new().read(&mut file) {
                Ok(c) => c,
                Err(_) => {
                    unreachable!()
                }
            }
        };

        let collection2 = {
            // Create a file inside of `std::env::temp_dir()`.
            let mut file1 = NamedTempFile::new()?;
            // Re-open it.
            let mut file2 = BufReader::new(file1.reopen()?);
            // Write some test data to the first handle.
            SlcWriter::new().write_collection(&collection1, &mut file1)?;
            // Read collect from second handle
            match SlcReader::new().read(&mut file2) {
                Ok(c) => c,
                Err(_) => {
                    unreachable!()
                }
            }
        };

        assert_eq!(collection1.title, collection2.title);
        assert_eq!(collection1.puzzles.len(), collection2.puzzles.len());
        assert_eq!(collection1.puzzles, collection2.puzzles);

        Ok(())
    }
}