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 = {
let mut file1 = NamedTempFile::new()?;
let mut file2 = BufReader::new(file1.reopen()?);
SlcWriter::new().write_collection(&collection1, &mut file1)?;
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(())
}
}