sashite-feen 0.1.0

Field Expression Encoding Notation (FEEN): a compact, ASCII-only, no_std, zero-allocation validator and encoder for board-game positions in abstract strategy games, built on EPIN and SIN.
Documentation
//! The style–turn field (field 3): `<active-style>/<inactive-style>`.
//!
//! Each side is a single [SIN](sashite_sin) token. Two attributions are layered
//! onto the field:
//!
//! - **Side, by case.** An uppercase token names the style of player side
//!   `first`, a lowercase token that of side `second`. The two tokens must
//!   therefore be of opposite case.
//! - **Turn, by position.** The left token is the active player, the right token
//!   the inactive one — so the active player's side is the case of the left
//!   token.

use sashite_sin::Identifier;

use crate::error::ParseError;

/// Validates the style–turn field and returns `(active, inactive)` styles.
pub(crate) fn validate(field: &[u8]) -> Result<(Identifier, Identifier), ParseError> {
    // Exactly one '/' delimiter: a SIN token is a single letter, so the only
    // slash present is the separator.
    let mut delimiter = None;
    let mut slashes = 0usize;
    for (idx, &b) in field.iter().enumerate() {
        if b == b'/' {
            slashes += 1;
            delimiter = Some(idx);
        }
    }
    let (1, Some(at)) = (slashes, delimiter) else {
        return Err(ParseError::InvalidStyleTurnDelimiter);
    };

    let active = parse_style(&field[..at])?;
    let inactive = parse_style(&field[at + 1..])?;

    // Case encodes side, so opposite case is equivalent to opposite side.
    if active.side() == inactive.side() {
        return Err(ParseError::StylesSameCase);
    }

    Ok((active, inactive))
}

/// Parses one side of the field as a SIN token.
fn parse_style(bytes: &[u8]) -> Result<Identifier, ParseError> {
    Identifier::try_from(bytes).map_err(|_| ParseError::InvalidStyleToken)
}

#[cfg(test)]
mod tests {
    use super::*;
    use sashite_sin::Side;

    fn ok(field: &str) -> (Side, char, char) {
        let (active, inactive) = validate(field.as_bytes()).expect("valid style-turn");
        (active.side(), active.to_char(), inactive.to_char())
    }
    fn err(field: &str) -> ParseError {
        validate(field.as_bytes()).unwrap_err()
    }

    #[test]
    fn first_active() {
        assert_eq!(ok("C/c"), (Side::First, 'C', 'c'));
    }
    #[test]
    fn second_active() {
        assert_eq!(ok("c/C"), (Side::Second, 'c', 'C'));
    }
    #[test]
    fn cross_style() {
        // active first plays Western (W), inactive second plays Chinese (c -> C)
        assert_eq!(ok("W/c"), (Side::First, 'W', 'c'));
    }
    #[test]
    fn cross_style_second_active() {
        assert_eq!(ok("w/C"), (Side::Second, 'w', 'C'));
    }

    #[test]
    fn same_case_rejected() {
        assert_eq!(err("C/C"), ParseError::StylesSameCase);
    }
    #[test]
    fn same_case_lower() {
        assert_eq!(err("c/c"), ParseError::StylesSameCase);
    }
    #[test]
    fn no_delimiter() {
        assert_eq!(err("C"), ParseError::InvalidStyleTurnDelimiter);
    }
    #[test]
    fn two_delimiters() {
        assert_eq!(err("C/c/W"), ParseError::InvalidStyleTurnDelimiter);
    }
    #[test]
    fn left_too_long() {
        assert_eq!(err("CC/c"), ParseError::InvalidStyleToken);
    }
    #[test]
    fn right_empty() {
        assert_eq!(err("C/"), ParseError::InvalidStyleToken);
    }
    #[test]
    fn left_empty() {
        assert_eq!(err("/c"), ParseError::InvalidStyleToken);
    }
    #[test]
    fn non_letter_token() {
        assert_eq!(err("1/c"), ParseError::InvalidStyleToken);
    }
}