rshogi-usi 0.1.5

Engine-agnostic USI protocol command model, parser, and formatter
Documentation
use crate::parser::{
    BestMove, BestMoveKind, CheckmateResponse, GameResult, GoMate, GoParams, InfoCommand,
    InfoScore, MateScore, PositionSpec, ScoreBound, ScoreValue, UsiCommand, UsiOption,
    UsiOptionKind,
};

#[must_use]
pub fn format_command(command: &UsiCommand) -> String {
    match command {
        UsiCommand::Usi => "usi".to_string(),
        UsiCommand::IsReady => "isready".to_string(),
        UsiCommand::UsiNewGame => "usinewgame".to_string(),
        UsiCommand::Stop => "stop".to_string(),
        UsiCommand::Quit => "quit".to_string(),
        UsiCommand::PonderHit => "ponderhit".to_string(),
        UsiCommand::SetOption { name, value } => format_setoption(name, value.as_deref()),
        UsiCommand::Position { spec, moves } => format_position(spec, moves),
        UsiCommand::Go(params) => format_go(params),
        UsiCommand::GameOver(result) => format_gameover(result),
        UsiCommand::Id { key, value } => format!("id {key} {value}"),
        UsiCommand::Option(option) => format_option(option),
        UsiCommand::UsiOk => "usiok".to_string(),
        UsiCommand::ReadyOk => "readyok".to_string(),
        UsiCommand::BestMove(bestmove) => format_bestmove(bestmove),
        UsiCommand::Info(info) => format_info(info),
        UsiCommand::Checkmate(response) => format_checkmate(response),
        UsiCommand::Extension { name, args } => format_simple_with_args(name, args),
    }
}

fn format_setoption(name: &str, value: Option<&str>) -> String {
    let mut out = format!("setoption name {name}");
    if let Some(value) = value {
        out.push_str(" value");
        if !value.is_empty() {
            out.push(' ');
            out.push_str(value);
        }
    }
    out
}

fn format_position(spec: &PositionSpec, moves: &[String]) -> String {
    let mut out = match spec {
        PositionSpec::StartPos => "position startpos".to_string(),
        PositionSpec::Sfen { board, side_to_move, hands, ply } => {
            format!("position sfen {board} {side_to_move} {hands} {ply}")
        }
    };

    if !moves.is_empty() {
        out.push_str(" moves ");
        out.push_str(&moves.join(" "));
    }

    out
}

fn format_go(params: &GoParams) -> String {
    let mut parts = vec!["go".to_string()];
    if params.ponder {
        parts.push("ponder".to_string());
    }
    push_opt_u64(&mut parts, "btime", params.btime);
    push_opt_u64(&mut parts, "wtime", params.wtime);
    push_opt_u64(&mut parts, "byoyomi", params.byoyomi);
    push_opt_u64(&mut parts, "binc", params.binc);
    push_opt_u64(&mut parts, "winc", params.winc);
    push_opt_u64(&mut parts, "movetime", params.movetime);
    push_opt_u64(&mut parts, "movestogo", params.movestogo);
    push_opt_u32(&mut parts, "depth", params.depth);
    push_opt_u64(&mut parts, "nodes", params.nodes);
    push_go_mate(&mut parts, params.mate.as_ref());
    if params.infinite {
        parts.push("infinite".to_string());
    }
    if !params.searchmoves.is_empty() {
        parts.push("searchmoves".to_string());
        parts.extend(params.searchmoves.iter().cloned());
    }
    parts.extend(params.extras.iter().cloned());
    parts.join(" ")
}

fn format_gameover(result: &GameResult) -> String {
    let result = match result {
        GameResult::Win => "win",
        GameResult::Lose => "lose",
        GameResult::Draw => "draw",
        GameResult::Other(other) => other,
    };

    format!("gameover {result}")
}

fn format_option(option: &UsiOption) -> String {
    let mut parts =
        vec!["option".to_string(), "name".to_string(), option.name.clone(), "type".to_string()];
    parts.push(format_option_kind(&option.kind).to_string());

    if let Some(default) = option.default.as_deref() {
        parts.push("default".to_string());
        parts.push(format_option_default(&option.kind, default));
    }
    if let Some(min) = option.min {
        parts.push("min".to_string());
        parts.push(min.to_string());
    }
    if let Some(max) = option.max {
        parts.push("max".to_string());
        parts.push(max.to_string());
    }
    for value in &option.vars {
        parts.push("var".to_string());
        parts.push(value.clone());
    }
    parts.extend(option.extras.iter().cloned());
    parts.join(" ")
}

fn format_option_default(kind: &UsiOptionKind, default: &str) -> String {
    if matches!(kind, UsiOptionKind::String | UsiOptionKind::Filename) && default.is_empty() {
        "<empty>".to_string()
    } else {
        default.to_string()
    }
}

fn format_bestmove(bestmove: &BestMove) -> String {
    let mut out = format!("bestmove {}", format_bestmove_kind(&bestmove.bestmove));
    if let Some(ponder) = bestmove.ponder.as_deref() {
        out.push_str(" ponder ");
        out.push_str(ponder);
    }
    out
}

fn format_info(info: &InfoCommand) -> String {
    let mut parts = vec!["info".to_string()];
    push_opt_u32(&mut parts, "depth", info.depth);
    push_opt_u32(&mut parts, "seldepth", info.seldepth);
    push_opt_u64(&mut parts, "time", info.time);
    push_opt_u64(&mut parts, "nodes", info.nodes);
    push_opt_u64(&mut parts, "nps", info.nps);
    push_opt_u32(&mut parts, "hashfull", info.hashfull);
    push_opt_u32(&mut parts, "multipv", info.multipv);
    push_info_score(&mut parts, info.score.as_ref());
    if let Some(currmove) = info.currmove.as_deref() {
        parts.push("currmove".to_string());
        parts.push(currmove.to_string());
    }
    parts.extend(info.extras.iter().cloned());
    if !info.pv.is_empty() {
        parts.push("pv".to_string());
        parts.extend(info.pv.iter().cloned());
    } else if let Some(string) = info.string.as_deref() {
        parts.push("string".to_string());
        if !string.is_empty() {
            parts.push(string.to_string());
        }
    }
    parts.join(" ")
}

fn format_checkmate(response: &CheckmateResponse) -> String {
    match response {
        CheckmateResponse::Moves(moves) => format_simple_with_args("checkmate", moves),
        CheckmateResponse::NotImplemented => "checkmate notimplemented".to_string(),
        CheckmateResponse::Timeout => "checkmate timeout".to_string(),
        CheckmateResponse::NoMate => "checkmate nomate".to_string(),
    }
}

fn format_option_kind(kind: &UsiOptionKind) -> &str {
    match kind {
        UsiOptionKind::Check => "check",
        UsiOptionKind::Spin => "spin",
        UsiOptionKind::Combo => "combo",
        UsiOptionKind::Button => "button",
        UsiOptionKind::String => "string",
        UsiOptionKind::Filename => "filename",
        UsiOptionKind::Other(other) => other,
    }
}

fn format_bestmove_kind(kind: &BestMoveKind) -> &str {
    match kind {
        BestMoveKind::Move(mv) => mv,
        BestMoveKind::Resign => "resign",
        BestMoveKind::Win => "win",
    }
}

fn push_opt_u64(parts: &mut Vec<String>, name: &str, value: Option<u64>) {
    if let Some(value) = value {
        parts.push(name.to_string());
        parts.push(value.to_string());
    }
}

fn push_opt_u32(parts: &mut Vec<String>, name: &str, value: Option<u32>) {
    if let Some(value) = value {
        parts.push(name.to_string());
        parts.push(value.to_string());
    }
}

fn push_go_mate(parts: &mut Vec<String>, value: Option<&GoMate>) {
    if let Some(value) = value {
        parts.push("mate".to_string());
        match value {
            GoMate::Ply(ply) => parts.push(ply.to_string()),
            GoMate::Infinite => parts.push("infinite".to_string()),
        }
    }
}

fn push_info_score(parts: &mut Vec<String>, score: Option<&InfoScore>) {
    let Some(score) = score else {
        return;
    };

    parts.push("score".to_string());
    match &score.value {
        ScoreValue::Cp(cp) => {
            parts.push("cp".to_string());
            parts.push(cp.to_string());
        }
        ScoreValue::Mate(mate) => {
            parts.push("mate".to_string());
            match mate {
                MateScore::Ply(ply) => parts.push(ply.to_string()),
                MateScore::UnknownWin => parts.push("+".to_string()),
                MateScore::UnknownLose => parts.push("-".to_string()),
            }
        }
    }

    match score.bound {
        Some(ScoreBound::Lower) => parts.push("lowerbound".to_string()),
        Some(ScoreBound::Upper) => parts.push("upperbound".to_string()),
        None => {}
    }
}

fn format_simple_with_args(head: &str, args: &[String]) -> String {
    if args.is_empty() {
        head.to_string()
    } else {
        format!("{head} {}", args.join(" "))
    }
}

#[cfg(test)]
mod tests {
    use crate::parser::{
        BestMove, CheckmateResponse, GoMate, GoParams, InfoCommand, InfoScore, MateScore,
        UsiCommand, UsiOption, UsiOptionKind,
    };

    use super::format_command;

    #[test]
    fn formats_go_command() {
        let command = UsiCommand::Go(GoParams {
            ponder: true,
            btime: Some(1_000),
            wtime: Some(2_000),
            mate: Some(GoMate::Ply(3)),
            searchmoves: vec!["7g7f".to_string()],
            ..GoParams::default()
        });
        assert_eq!(
            format_command(&command),
            "go ponder btime 1000 wtime 2000 mate 3 searchmoves 7g7f"
        );
    }

    #[test]
    fn formats_go_mate_infinite() {
        let command =
            UsiCommand::Go(GoParams { mate: Some(GoMate::Infinite), ..GoParams::default() });
        assert_eq!(format_command(&command), "go mate infinite");
    }

    #[test]
    fn formats_bestmove_with_ponder() {
        let command = UsiCommand::bestmove(BestMove::move_to("8h2b+").with_ponder("3a2b"));
        assert_eq!(format_command(&command), "bestmove 8h2b+ ponder 3a2b");
    }

    #[test]
    fn formats_option_with_defaults() {
        let command = UsiCommand::Option(UsiOption {
            name: "Style".to_string(),
            kind: UsiOptionKind::Combo,
            default: Some("Normal".to_string()),
            min: None,
            max: None,
            vars: vec!["Solid".to_string(), "Normal".to_string(), "Risky".to_string()],
            extras: Vec::new(),
        });
        assert_eq!(
            format_command(&command),
            "option name Style type combo default Normal var Solid var Normal var Risky"
        );
    }

    #[test]
    fn formats_empty_filename_default_as_empty_sentinel() {
        let command = UsiCommand::Option(UsiOption::filename("EvalFile", ""));
        assert_eq!(format_command(&command), "option name EvalFile type filename default <empty>");
    }

    #[test]
    fn formats_info_score_and_pv() {
        let command = UsiCommand::info(
            InfoCommand::new()
                .with_depth(12)
                .with_nodes(1_024)
                .with_score(InfoScore::mate(MateScore::unknown_win()).with_lowerbound())
                .with_pv(["7g7f", "3c3d"]),
        );
        assert_eq!(
            format_command(&command),
            "info depth 12 nodes 1024 score mate + lowerbound pv 7g7f 3c3d"
        );
    }

    #[test]
    fn formats_info_without_pv_or_string() {
        let command = UsiCommand::info(
            InfoCommand::new()
                .with_hashfull(104)
                .with_multipv_usize(2)
                .with_score(InfoScore::cp(-34).with_upperbound()),
        );
        assert_eq!(format_command(&command), "info hashfull 104 multipv 2 score cp -34 upperbound");
    }

    #[test]
    fn formats_checkmate_timeout() {
        let command = UsiCommand::Checkmate(CheckmateResponse::Timeout);
        assert_eq!(format_command(&command), "checkmate timeout");
    }
}