use std::fs::File;
use std::io::{BufRead, BufReader, BufWriter, Read, Seek, Write};
use std::path::Path;
#[cfg(feature = "json")]
use super::json_file_format::{JsonReader, JsonWriter};
use super::slc_file_format::{SlcReader, SlcWriter};
use super::sok_file_format::{SokReader, SokWriter};
use super::{IoReadError, IoWriteError, ReaderSettings, WriterSettings};
use crate::{Collection, Puzzle, Tessellation};
#[cfg_attr(
feature = "json",
doc = "| `.json` | Any [`Tessellation`] puzzles, allows mixing different [`Tessellation`] in the same file |"
)]
#[doc = ""]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[non_exhaustive]
pub enum FileFormat {
SokobanSLC,
Xsb,
Txt,
Hsb,
Tsb,
SokobanYASC,
#[cfg(feature = "json")]
Json,
}
impl FileFormat {
const SLC_EXT: &'static str = "slc";
const TXT_EXT: &'static str = "txt";
const HSB_EXT: &'static str = "hsb";
const TSB_EXT: &'static str = "tsb";
const XSB_EXT: &'static str = "xsb";
const SOK_EXT: &'static str = "sok";
#[cfg(feature = "json")]
const JSON_EXT: &'static str = "json";
pub fn file_extension(&self) -> &str {
match self {
FileFormat::SokobanYASC => FileFormat::SOK_EXT,
FileFormat::SokobanSLC => FileFormat::SLC_EXT,
FileFormat::Txt => FileFormat::TXT_EXT,
FileFormat::Xsb => FileFormat::XSB_EXT,
FileFormat::Hsb => FileFormat::HSB_EXT,
FileFormat::Tsb => FileFormat::TSB_EXT,
#[cfg(feature = "json")]
FileFormat::Json => FileFormat::JSON_EXT,
}
}
pub fn restricted_to_tessellation(&self) -> Option<Tessellation> {
match self {
FileFormat::SokobanSLC => Some(Tessellation::Sokoban),
FileFormat::Xsb => Some(Tessellation::Sokoban),
FileFormat::Txt => Some(Tessellation::Sokoban),
FileFormat::Tsb => Some(Tessellation::Trioban),
FileFormat::Hsb => Some(Tessellation::Hexoban),
FileFormat::SokobanYASC => None,
#[cfg(feature = "json")]
FileFormat::Json => None,
}
}
pub fn from_file_extension(path: impl AsRef<Path>) -> Option<Self> {
let ext = path.as_ref().extension()?.to_ascii_lowercase();
match ext.to_str()? {
Self::SLC_EXT => Some(Self::SokobanSLC),
Self::XSB_EXT => Some(Self::Xsb),
Self::TXT_EXT => Some(Self::Txt),
Self::HSB_EXT => Some(Self::Hsb),
Self::TSB_EXT => Some(Self::Tsb),
Self::SOK_EXT => Some(Self::SokobanYASC),
#[cfg(feature = "json")]
Self::JSON_EXT => Some(Self::Json),
_ => None,
}
}
pub fn read<R>(src: &mut R, settings: ReaderSettings) -> Result<Collection, IoReadError>
where
R: Read + BufRead + Seek,
{
let tessellation_hint = settings.file_format.restricted_to_tessellation();
match settings.file_format {
FileFormat::SokobanSLC => SlcReader::new().read(src),
FileFormat::SokobanYASC
| FileFormat::Xsb
| FileFormat::Hsb
| FileFormat::Tsb
| FileFormat::Txt => {
SokReader::new(tessellation_hint, settings.legacy_sokoban_plus).read(src)
}
#[cfg(feature = "json")]
FileFormat::Json => JsonReader::new().read(src),
}
}
pub fn read_from_path(
src_file_path: impl AsRef<Path>, settings: Option<ReaderSettings>,
) -> Result<Collection, IoReadError> {
if let Some(file_format) = Self::from_file_extension(&src_file_path) {
let mut reader = BufReader::new(File::open(&src_file_path)?);
let settings = if let Some(s) = settings {
ReaderSettings { file_format, ..s }
} else {
ReaderSettings {
file_format,
..ReaderSettings::default()
}
};
Self::read(&mut reader, settings)
} else {
let extension: String = match src_file_path.as_ref().extension() {
Some(s) => s.to_string_lossy().to_string(),
None => String::new(),
};
Err(IoReadError::new_invalid_extension_error(&extension))
}
}
pub fn write<W>(
dest: &mut W, collection: &Collection, settings: WriterSettings,
) -> Result<(), IoWriteError>
where
W: Write,
{
let mut dest = BufWriter::new(dest);
let restrict_tessellation = settings.file_format.restricted_to_tessellation();
match settings.file_format {
FileFormat::SokobanSLC => SlcWriter::new().write_collection(collection, &mut dest),
FileFormat::SokobanYASC => {
SokWriter::new(None, false, settings).write_collection(collection, &mut dest)
}
FileFormat::Xsb | FileFormat::Hsb | FileFormat::Tsb | FileFormat::Txt => {
SokWriter::new(restrict_tessellation, true, settings)
.write_collection(collection, &mut dest)
}
#[cfg(feature = "json")]
FileFormat::Json => JsonWriter::new().write_collection(collection, &mut dest),
}?;
dest.flush().map_err(|e| IoWriteError::new_io_error(e, 0))
}
pub fn save_to_file(
&self, collection: &Collection, dest_file_path: impl AsRef<Path>,
settings: Option<WriterSettings>,
) -> Result<(), IoWriteError> {
if let Some(file_format) = FileFormat::from_file_extension(&dest_file_path) {
let mut dest = File::create(&dest_file_path)?;
let settings = if let Some(s) = settings {
WriterSettings { file_format, ..s }
} else {
WriterSettings {
file_format,
..WriterSettings::default()
}
};
FileFormat::write(&mut dest, collection, settings)
} else {
let extension: String = match dest_file_path.as_ref().extension() {
Some(s) => s.to_string_lossy().to_string(),
None => String::new(),
};
Err(IoWriteError::new_invalid_extension_error(&extension))
}
}
}
pub(in crate::io) trait FileFormatReader {
fn read<R>(&mut self, src: &mut R) -> Result<Collection, IoReadError>
where
R: Read + BufRead + Seek;
}
pub(in crate::io) trait FileFormatWriter {
fn write_collection<W>(
&self, collection: &Collection, dest: &mut W,
) -> Result<(), IoWriteError>
where
W: Write;
#[expect(dead_code)]
fn write_puzzle<W>(
&self, puzzle: &Puzzle, puzzle_idx: usize, dest: &mut W,
) -> Result<(), IoWriteError>
where
W: Write;
}
#[cfg(test)]
mod tests {
use std::io::Cursor;
use std::path::PathBuf;
use super::*;
macro_rules! tests_data_dir {
() => {
concat!(env!("CARGO_MANIFEST_DIR"), "/src/io/tests_data/")
};
}
#[cfg(feature = "json")]
const JSON_FORMATS: &[FileFormat] = &[FileFormat::Json];
#[cfg(not(feature = "json"))]
const JSON_FORMATS: &[FileFormat] = &[];
fn all_file_formats() -> Vec<FileFormat> {
let mut retv = vec![
FileFormat::SokobanYASC,
FileFormat::SokobanSLC,
FileFormat::Txt,
FileFormat::Xsb,
FileFormat::Hsb,
FileFormat::Tsb,
];
retv.extend_from_slice(JSON_FORMATS);
retv
}
fn lossless_file_formats() -> Vec<FileFormat> {
let mut retv = vec![FileFormat::SokobanYASC];
retv.extend_from_slice(JSON_FORMATS);
retv
}
fn small_collection() -> Result<Collection, IoReadError> {
let src_file_path = PathBuf::from(tests_data_dir!()).join("small_collection.sok");
FileFormat::read_from_path(src_file_path, None)
}
#[test]
fn it_maps_every_file_extension_to_its_format() {
for file_format in all_file_formats() {
let path = format!("collection.{}", file_format.file_extension());
assert_eq!(FileFormat::from_file_extension(&path), Some(file_format));
assert_eq!(
FileFormat::from_file_extension(path.to_uppercase()),
Some(file_format)
);
}
}
#[test]
fn when_extension_is_unknown_there_is_no_format() {
assert_eq!(FileFormat::from_file_extension("collection.bin"), None);
assert_eq!(FileFormat::from_file_extension("collection"), None);
assert_eq!(FileFormat::from_file_extension(""), None);
}
struct FailingWriter;
impl Write for FailingWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "no"))
}
fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
}
fn tiny_collection() -> Result<Collection, crate::BoardSyntaxError> {
let mut collection = Collection::new();
collection.puzzles.push(Puzzle {
board: crate::GameBoard::parsed("#@$.#", Tessellation::Sokoban)?,
title: "Tiny".to_string(),
author: String::new(),
notes: String::new(),
saved_games: Vec::new(),
sokoban_plus: None,
});
Ok(collection)
}
#[test]
fn when_the_destination_fails_writing_reports_it() -> Result<(), Box<dyn std::error::Error>> {
let collection = tiny_collection()?;
for file_format in all_file_formats() {
let result = FileFormat::write(
&mut FailingWriter,
&collection,
WriterSettings {
file_format,
..WriterSettings::default()
},
);
assert!(result.is_err(), "{file_format:?} reported success");
}
Ok(())
}
#[test]
fn it_round_trips_a_collection_through_write_and_read() -> Result<(), Box<dyn std::error::Error>>
{
let collection = small_collection()?;
for file_format in lossless_file_formats() {
let mut data: Vec<u8> = Vec::new();
FileFormat::write(
&mut data,
&collection,
WriterSettings {
file_format,
..WriterSettings::default()
},
)?;
let reread = FileFormat::read(
&mut Cursor::new(data),
ReaderSettings {
file_format,
..ReaderSettings::default()
},
)?;
assert_eq!(reread, collection, "failed for {file_format:?}");
}
Ok(())
}
#[test]
fn it_round_trips_a_collection_through_a_file() -> Result<(), Box<dyn std::error::Error>> {
let collection = small_collection()?;
for file_format in lossless_file_formats() {
let file = tempfile::Builder::new()
.suffix(&format!(".{}", file_format.file_extension()))
.tempfile()?;
file_format.save_to_file(&collection, file.path(), None)?;
let reread = FileFormat::read_from_path(file.path(), None)?;
assert_eq!(reread, collection, "failed for {file_format:?}");
}
Ok(())
}
}