use crate::engine::board::Position;
use crate::engine::movegen;
use crate::engine::types::{Move, PieceType, Square};
pub fn move_to_san(pos: &Position, mv: Move, legal_moves: &[Move]) -> String {
if mv.flags.is_castling() {
return if mv.to.file() > mv.from.file() {
"O-O".into()
} else {
"O-O-O".into()
};
}
let piece = pos
.piece_at(mv.from)
.map(|(_, pt)| pt)
.expect("SAN: no piece on from square");
let mut san = String::with_capacity(8);
if piece == PieceType::Pawn {
if mv.flags.is_capture() || mv.flags.is_en_passant() {
san.push((b'a' + mv.from.file()) as char);
san.push('x');
}
san.push_str(&mv.to.to_algebraic());
if let Some(promo) = mv.promotion {
san.push('=');
san.push(piece_letter(promo));
}
} else {
san.push(piece_letter(piece));
let disambig = disambiguation(pos, mv, piece, legal_moves);
san.push_str(&disambig);
if mv.flags.is_capture() {
san.push('x');
}
san.push_str(&mv.to.to_algebraic());
}
san
}
fn disambiguation(pos: &Position, mv: Move, piece: PieceType, legal_moves: &[Move]) -> String {
let us = pos.side_to_move;
let ambiguous: Vec<&Move> = legal_moves
.iter()
.filter(|m| {
m.to == mv.to
&& m.from != mv.from
&& !m.flags.is_castling()
&& pos
.piece_at(m.from)
.map(|(c, pt)| c == us && pt == piece)
.unwrap_or(false)
})
.collect();
if ambiguous.is_empty() {
return String::new();
}
let same_file = ambiguous.iter().any(|m| m.from.file() == mv.from.file());
let same_rank = ambiguous.iter().any(|m| m.from.rank() == mv.from.rank());
match (same_file, same_rank) {
(false, _) => {
format!("{}", (b'a' + mv.from.file()) as char)
}
(true, false) => {
format!("{}", (b'1' + mv.from.rank()) as char)
}
(true, true) => {
format!(
"{}{}",
(b'a' + mv.from.file()) as char,
(b'1' + mv.from.rank()) as char
)
}
}
}
fn piece_letter(pt: PieceType) -> char {
match pt {
PieceType::Pawn => 'P',
PieceType::Knight => 'N',
PieceType::Bishop => 'B',
PieceType::Rook => 'R',
PieceType::Queen => 'Q',
PieceType::King => 'K',
}
}
pub fn parse_san(pos: &Position, san: &str) -> Result<Move, crate::engine::types::ChessError> {
let legal = movegen::legal_moves(pos);
let san = san.trim_end_matches(['+', '#', '!', '?']);
if san == "O-O" || san == "0-0" {
return find_castling(pos, &legal, true);
}
if san == "O-O-O" || san == "0-0-0" {
return find_castling(pos, &legal, false);
}
let chars: Vec<char> = san.chars().collect();
if chars.is_empty() {
return Err(crate::engine::types::ChessError::InvalidMove {
from: String::new(),
to: String::new(),
reason: "empty SAN string".into(),
});
}
let (chars, promotion) = if chars.len() >= 2 && chars[chars.len() - 2] == '=' {
let promo_char = chars[chars.len() - 1];
let promo = match promo_char {
'Q' | 'q' => PieceType::Queen,
'R' | 'r' => PieceType::Rook,
'B' | 'b' => PieceType::Bishop,
'N' | 'n' => PieceType::Knight,
_ => {
return Err(crate::engine::types::ChessError::InvalidPromotion(
promo_char.to_string(),
));
}
};
(&chars[..chars.len() - 2], Some(promo))
} else {
(&chars[..], None)
};
let (piece, rest) = if chars[0].is_uppercase() && "NBRQK".contains(chars[0]) {
let pt = match chars[0] {
'N' => PieceType::Knight,
'B' => PieceType::Bishop,
'R' => PieceType::Rook,
'Q' => PieceType::Queen,
'K' => PieceType::King,
_ => unreachable!(),
};
(pt, &chars[1..])
} else {
(PieceType::Pawn, chars)
};
let rest: Vec<char> = rest.iter().copied().filter(|&c| c != 'x').collect();
if rest.len() < 2 {
return Err(crate::engine::types::ChessError::InvalidMove {
from: String::new(),
to: san.to_string(),
reason: "SAN too short".into(),
});
}
let dest_str: String = rest[rest.len() - 2..].iter().collect();
let dest = Square::from_algebraic(&dest_str)
.ok_or_else(|| crate::engine::types::ChessError::InvalidSquare(dest_str.clone()))?;
let disambig = &rest[..rest.len() - 2];
let disambig_file: Option<u8> = disambig
.iter()
.find(|c| c.is_ascii_lowercase())
.map(|&c| c as u8 - b'a');
let disambig_rank: Option<u8> = disambig
.iter()
.find(|c| c.is_ascii_digit())
.map(|&c| c as u8 - b'1');
let us = pos.side_to_move;
let candidates: Vec<&Move> = legal
.iter()
.filter(|m| {
if m.to != dest {
return false;
}
if let Some((color, pt)) = pos.piece_at(m.from) {
if color != us || pt != piece {
return false;
}
} else {
return false;
}
if let Some(f) = disambig_file
&& m.from.file() != f
{
return false;
}
if let Some(r) = disambig_rank
&& m.from.rank() != r
{
return false;
}
if let Some(promo) = promotion {
if m.promotion != Some(promo) {
return false;
}
} else if m.promotion.is_some() {
return false;
}
true
})
.collect();
match candidates.len() {
0 => Err(crate::engine::types::ChessError::InvalidMove {
from: String::new(),
to: san.to_string(),
reason: format!("no legal move matches SAN '{san}'"),
}),
1 => Ok(*candidates[0]),
_ => Err(crate::engine::types::ChessError::InvalidMove {
from: String::new(),
to: san.to_string(),
reason: format!("ambiguous SAN '{san}': {} candidates", candidates.len()),
}),
}
}
fn find_castling(
pos: &Position,
legal: &[Move],
kingside: bool,
) -> Result<Move, crate::engine::types::ChessError> {
let king_sq = pos.king_sq(pos.side_to_move);
let target_file = if kingside { 6 } else { 2 };
legal
.iter()
.find(|m| m.flags.is_castling() && m.from == king_sq && m.to.file() == target_file)
.copied()
.ok_or_else(|| crate::engine::types::ChessError::InvalidMove {
from: king_sq.to_algebraic(),
to: String::new(),
reason: format!(
"castling {} not legal",
if kingside { "kingside" } else { "queenside" }
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::board::Position;
use crate::engine::movegen;
use crate::engine::types::MoveFlags;
fn sq(name: &str) -> Square {
Square::from_algebraic(name).unwrap()
}
fn pos(fen: &str) -> Position {
Position::from_fen(fen).unwrap()
}
fn san(fen: &str, from: &str, to: &str, flags: MoveFlags) -> String {
let p = pos(fen);
let legal = movegen::legal_moves(&p);
let mv = Move::with_flags(sq(from), sq(to), flags);
move_to_san(&p, mv, &legal)
}
fn san_promo(fen: &str, from: &str, to: &str, promo: PieceType, flags: MoveFlags) -> String {
let p = pos(fen);
let legal = movegen::legal_moves(&p);
let mv = Move::with_promotion(sq(from), sq(to), promo, flags);
move_to_san(&p, mv, &legal)
}
#[test]
fn san_pawn_push() {
assert_eq!(
san(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"e2",
"e4",
MoveFlags::DOUBLE_PUSH
),
"e4"
);
}
#[test]
fn san_pawn_capture() {
assert_eq!(
san(
"rnbqkbnr/ppp1pppp/8/3p4/4P3/8/PPPP1PPP/RNBQKBNR w KQkq d6 0 2",
"e4",
"d5",
MoveFlags::CAPTURE
),
"exd5"
);
}
#[test]
fn san_pawn_promotion() {
assert_eq!(
san_promo(
"7k/4P3/8/8/8/8/8/4K3 w - - 0 1",
"e7",
"e8",
PieceType::Queen,
MoveFlags::NONE
),
"e8=Q"
);
}
#[test]
fn san_en_passant() {
assert_eq!(
san(
"rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3",
"e5",
"f6",
MoveFlags::CAPTURE | MoveFlags::EN_PASSANT
),
"exf6"
);
}
#[test]
fn san_knight_move() {
assert_eq!(
san(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
"g1",
"f3",
MoveFlags::NONE
),
"Nf3"
);
}
#[test]
fn san_bishop_capture() {
assert_eq!(
san(
"rnbqk1nr/pppp1ppp/4p3/8/1b6/2N5/PPPPPPPP/R1BQKBNR b KQkq - 2 2",
"b4",
"c3",
MoveFlags::CAPTURE
),
"Bxc3"
);
}
#[test]
fn san_castling_kingside() {
assert_eq!(
san(
"r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1",
"e1",
"g1",
MoveFlags::CASTLING
),
"O-O"
);
}
#[test]
fn san_castling_queenside() {
assert_eq!(
san(
"r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1",
"e1",
"c1",
MoveFlags::CASTLING
),
"O-O-O"
);
}
#[test]
fn san_rook_file_disambiguation() {
assert_eq!(
san(
"4k3/8/8/8/8/4K3/8/R6R w - - 0 1",
"a1",
"e1",
MoveFlags::NONE
),
"Rae1"
);
}
#[test]
fn san_rook_rank_disambiguation() {
assert_eq!(
san(
"R3k3/8/8/8/8/8/8/R3K3 w - - 0 1",
"a1",
"a4",
MoveFlags::NONE
),
"R1a4"
);
}
#[test]
fn parse_san_pawn_push() {
let p = Position::starting();
let mv = parse_san(&p, "e4").unwrap();
assert_eq!(mv.from, sq("e2"));
assert_eq!(mv.to, sq("e4"));
}
#[test]
fn parse_san_knight_move() {
let p = Position::starting();
let mv = parse_san(&p, "Nf3").unwrap();
assert_eq!(mv.from, sq("g1"));
assert_eq!(mv.to, sq("f3"));
}
#[test]
fn parse_san_castling() {
let p = pos("r3k2r/pppppppp/8/8/8/8/PPPPPPPP/R3K2R w KQkq - 0 1");
let mv = parse_san(&p, "O-O").unwrap();
assert_eq!(mv.to.file(), 6);
assert!(mv.flags.is_castling());
}
#[test]
fn parse_san_promotion() {
let p = pos("7k/4P3/8/8/8/8/8/4K3 w - - 0 1");
let mv = parse_san(&p, "e8=Q").unwrap();
assert_eq!(mv.promotion, Some(PieceType::Queen));
}
#[test]
fn parse_san_capture_with_check() {
let p = pos("r1bqkbnr/pppppppp/2n5/4P3/8/8/PPPP1PPP/RNBQKBNR b KQkq - 0 2");
let result = parse_san(&p, "d5+");
assert!(result.is_ok() || result.is_err()); }
#[test]
fn parse_san_invalid() {
let p = Position::starting();
assert!(parse_san(&p, "Qh5").is_err()); }
#[test]
fn san_round_trip_starting_position() {
let p = Position::starting();
let legal = movegen::legal_moves(&p);
for mv in &legal {
let san_str = move_to_san(&p, *mv, &legal);
let parsed = parse_san(&p, &san_str).unwrap();
assert_eq!(
parsed.to, mv.to,
"round-trip failed for SAN '{san_str}': expected to={}, got to={}",
mv.to, parsed.to
);
assert_eq!(
parsed.from, mv.from,
"round-trip failed for SAN '{san_str}': expected from={}, got from={}",
mv.from, parsed.from
);
}
}
#[test]
fn san_round_trip_kiwipete() {
let p = pos("r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1");
let legal = movegen::legal_moves(&p);
for mv in &legal {
let san_str = move_to_san(&p, *mv, &legal);
let parsed = parse_san(&p, &san_str).unwrap();
assert_eq!(
parsed.to, mv.to,
"kiwipete round-trip failed for SAN '{san_str}'"
);
assert_eq!(
parsed.from, mv.from,
"kiwipete round-trip from failed for SAN '{san_str}'"
);
}
}
}