rsshogi 1.0.2

Reusable Rust shogi primitives for board state, move generation, legality, and record parsing.
Documentation
use crate::board::{Position, SfenError};
use crate::records::error::RecordError;
use crate::records::record::{
    MoveEntry, Record, RecordEditor, RecordInitialPosition, SpecialMove, SpecialMoveEntry,
};
use crate::types::{Color, GameResult, Move};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UsiPositionError {
    #[error("USI position command must not be empty")]
    Empty,
    #[error("invalid USI position token sequence: {0}")]
    InvalidTokenSequence(String),
    #[error("invalid USI position SFEN: {0}")]
    Sfen(#[from] SfenError),
    #[error("invalid move token at index {index}: {token}")]
    InvalidMove { index: usize, token: String },
    #[error("special token is not allowed in strict USI position mode at index {index}: {token}")]
    SpecialTokenNotAllowed { index: usize, token: String },
    #[error("move appears after terminal special token at index {index}: {token}")]
    MoveAfterSpecial { index: usize, token: String },
    #[error("record construction failed: {0}")]
    Record(#[from] RecordError),
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct UsiPositionParseOptions {
    pub allow_special_tokens: bool,
}

impl UsiPositionParseOptions {
    #[must_use]
    pub const fn strict() -> Self {
        Self { allow_special_tokens: false }
    }

    #[must_use]
    pub const fn extended() -> Self {
        Self { allow_special_tokens: true }
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub struct UsiPositionExportOptions {
    pub include_special_tokens: bool,
}

impl UsiPositionExportOptions {
    #[must_use]
    pub const fn strict() -> Self {
        Self { include_special_tokens: false }
    }

    #[must_use]
    pub const fn extended() -> Self {
        Self { include_special_tokens: true }
    }
}

pub fn export_usi_position(record: &Record) -> Result<String, UsiPositionError> {
    export_usi_position_with_options(record, UsiPositionExportOptions::strict())
}

pub fn export_usi_position_with_options(
    record: &Record,
    options: UsiPositionExportOptions,
) -> Result<String, UsiPositionError> {
    let mut position = Position::empty();
    let mut parts = Vec::new();
    parts.push("position".to_string());
    match record.initial_position() {
        RecordInitialPosition::Startpos => {
            position.set_hirate();
            parts.push("startpos".to_string());
        }
        RecordInitialPosition::Sfen(sfen) => {
            position.set_sfen(sfen)?;
            parts.push("sfen".to_string());
            parts.extend(sfen.split_whitespace().map(ToOwned::to_owned));
        }
    }

    let mut move_tokens = Vec::new();
    for mv in record.main_moves() {
        if !position.is_legal_move(mv.mv()) {
            return Err(RecordError::IllegalMove.into());
        }
        move_tokens.push(mv.mv().to_usi());
        position.apply_move(mv.mv());
    }

    if options.include_special_tokens
        && let Some(terminal) = record.main_terminal()
        && let Some(token) = special_to_usi_token(terminal, position.turn())
    {
        move_tokens.push(token);
    }

    if !move_tokens.is_empty() {
        parts.push("moves".to_string());
        parts.extend(move_tokens);
    }

    Ok(parts.join(" "))
}

pub fn parse_usi_position(text: &str) -> Result<Record, UsiPositionError> {
    parse_usi_position_with_options(text, UsiPositionParseOptions::strict())
}

pub fn parse_usi_position_with_options(
    text: &str,
    options: UsiPositionParseOptions,
) -> Result<Record, UsiPositionError> {
    let text = text.trim();
    if text.is_empty() {
        return Err(UsiPositionError::Empty);
    }
    let text = text.strip_prefix("position ").map_or(text, str::trim);
    let tokens: Vec<&str> = text.split_whitespace().collect();
    if tokens.is_empty() {
        return Err(UsiPositionError::Empty);
    }

    let (initial_position, move_start) = match tokens[0] {
        "startpos" => (RecordInitialPosition::Startpos, 1),
        "sfen" => {
            if tokens.len() < 5 {
                return Err(UsiPositionError::InvalidTokenSequence(text.to_string()));
            }
            let sfen = tokens[1..5].join(" ");
            validate_sfen(&sfen)?;
            (RecordInitialPosition::Sfen(sfen), 5)
        }
        _ => {
            if tokens.len() < 4 {
                return Err(UsiPositionError::InvalidTokenSequence(text.to_string()));
            }
            let sfen = tokens[..4].join(" ");
            validate_sfen(&sfen)?;
            (RecordInitialPosition::Sfen(sfen), 4)
        }
    };

    let mut editor = RecordEditor::new(initial_position)?;
    if move_start == tokens.len() {
        return Ok(editor.into_record());
    }
    if tokens[move_start] != "moves" {
        return Err(UsiPositionError::InvalidTokenSequence(text.to_string()));
    }

    let mut terminal_seen = false;
    for (index, token) in tokens[(move_start + 1)..].iter().enumerate() {
        if terminal_seen {
            return Err(UsiPositionError::MoveAfterSpecial { index, token: (*token).to_string() });
        }
        if let Some(special) = special_from_usi_token(token, editor.position().turn()) {
            if !options.allow_special_tokens {
                return Err(UsiPositionError::SpecialTokenNotAllowed {
                    index,
                    token: (*token).to_string(),
                });
            }
            editor.append_special(special)?;
            terminal_seen = true;
            continue;
        }
        let mv = Move::from_usi(token)
            .filter(|mv| mv.is_normal())
            .ok_or_else(|| UsiPositionError::InvalidMove { index, token: (*token).to_string() })?;
        editor.append_move(MoveEntry::new(mv))?;
    }

    Ok(editor.into_record())
}

fn validate_sfen(sfen: &str) -> Result<(), SfenError> {
    let mut position = Position::empty();
    position.set_sfen(sfen)
}

fn special_from_usi_token(token: &str, side_to_move: Color) -> Option<SpecialMoveEntry> {
    let lower = token.to_ascii_lowercase();
    let winner = side_to_move.flip();
    let entry = match lower.as_str() {
        "resign" => SpecialMoveEntry::new(SpecialMove::Resign, GameResult::win_from_color(winner)),
        "win" => SpecialMoveEntry::new(
            SpecialMove::WinByDeclaration,
            GameResult::win_by_declaration_from_color(side_to_move),
        ),
        "draw" | "sennichite" | "repetition" => {
            SpecialMoveEntry::new(SpecialMove::RepetitionDraw, GameResult::DrawByRepetition)
        }
        "max_moves" | "maxmoves" => {
            SpecialMoveEntry::new(SpecialMove::MaxMoves, GameResult::DrawByMaxPlies)
        }
        "jishogi" | "impasse" => {
            SpecialMoveEntry::new(SpecialMove::Impasse, GameResult::DrawByImpasse)
        }
        "time_up" | "timeout" => SpecialMoveEntry::new(
            SpecialMove::Timeout,
            GameResult::win_by_timeout_from_color(winner),
        ),
        "interrupt" | "chudan" => SpecialMoveEntry::new(SpecialMove::Interrupt, GameResult::Paused),
        "mate" => SpecialMoveEntry::new(SpecialMove::Mate, GameResult::win_from_color(winner)),
        "nomate" | "no_mate" => {
            SpecialMoveEntry::new(SpecialMove::NoMate, GameResult::DrawByMaxPlies)
        }
        _ => return None,
    };
    Some(entry.with_raw(Some(token.to_string())))
}

fn special_to_usi_token(special: &SpecialMoveEntry, side_to_move: Color) -> Option<String> {
    if let Some(raw) = special.raw()
        && special_from_usi_token(raw, side_to_move).is_some()
    {
        return Some(raw.to_string());
    }
    match special.kind() {
        SpecialMove::Resign => Some("resign".to_string()),
        SpecialMove::WinByDeclaration | SpecialMove::Try => Some("win".to_string()),
        SpecialMove::Draw => Some("draw".to_string()),
        SpecialMove::RepetitionDraw => Some("sennichite".to_string()),
        SpecialMove::MaxMoves => Some("max_moves".to_string()),
        SpecialMove::Impasse => Some("jishogi".to_string()),
        SpecialMove::Timeout => Some("time_up".to_string()),
        SpecialMove::Interrupt => Some("interrupt".to_string()),
        SpecialMove::Mate => Some("mate".to_string()),
        SpecialMove::NoMate => Some("nomate".to_string()),
        SpecialMove::Unknown(name) => Some(name.to_ascii_lowercase()),
        SpecialMove::WinByIllegalMove
        | SpecialMove::LoseByIllegalMove
        | SpecialMove::WinByDefault
        | SpecialMove::LoseByDefault => None,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::board::InitialPosition as BoardInitialPosition;

    #[test]
    fn parse_startpos_preserves_startpos_token() {
        let record = parse_usi_position("position startpos moves 7g7f 3c3d").expect("parse");
        assert!(record.initial_position().is_startpos());
        assert_eq!(
            record.moves().iter().map(|mv| mv.mv().to_usi()).collect::<Vec<_>>(),
            ["7g7f", "3c3d"]
        );
        assert_eq!(
            export_usi_position(&record).expect("export"),
            "position startpos moves 7g7f 3c3d"
        );
    }

    #[test]
    fn parse_sfen_preserves_sfen_token() {
        let text = "position sfen lnsgkgsnl/1r5b1/ppppppppp/9/9/9/PPPPPPPPP/1B5R1/LNSGKGSNL b - 1";
        let record = parse_usi_position(text).expect("parse");
        assert!(!record.initial_position().is_startpos());
        assert_eq!(export_usi_position(&record).expect("export"), text);
    }

    #[test]
    fn strict_parse_rejects_special_tokens() {
        let err = parse_usi_position("position startpos moves resign").expect_err("strict error");
        assert!(matches!(err, UsiPositionError::SpecialTokenNotAllowed { .. }));
    }

    #[test]
    fn extended_parse_and_export_special_tokens() {
        let record = parse_usi_position_with_options(
            "position startpos moves 7g7f resign",
            UsiPositionParseOptions::extended(),
        )
        .expect("parse");
        assert_eq!(record.result(), GameResult::BlackWin);
        assert_eq!(
            export_usi_position_with_options(&record, UsiPositionExportOptions::extended())
                .expect("export"),
            "position startpos moves 7g7f resign"
        );
    }

    #[test]
    fn bare_sfen_is_accepted() {
        let sfen = BoardInitialPosition::Standard.to_sfen();
        let record = parse_usi_position(sfen).expect("parse");
        assert_eq!(record.init_position_sfen(), sfen);
    }
}