Skip to main content

supreme_hook_config/
validation.rs

1//! Shared field-validation tags for mod-authored inputs.
2//!
3//! A `validation` tag on a [`crate::ui::UiField`] or a
4//! [`crate::mod_config::ModParameter`] names a rule set that both the
5//! launcher (live, while the user types) and the backend (authoritative,
6//! on receipt) enforce identically. The rules live here, one module, so
7//! the client-side refusal and a server-side one can never drift apart:
8//! the tournament room-name check is mirrored in PHP by contract, and
9//! both sides quote the same limits and charsets.
10//!
11//! Two tags ship with this launcher generation:
12//!
13//! - [`PLAYER_NAME_TAG`], the tournament player identity: 1 to 9 ASCII
14//!   letters. The name travels inside every replay `.dat` and lands in
15//!   the game's high-times table, so it stays as narrow as the game's
16//!   own name handling needs.
17//! - [`ROOM_NAME_TAG`], the room display name: up to 24 printable
18//!   extended-ASCII characters (what a 1999 Windows application displays
19//!   without surprises: ASCII 0x20-0x7E plus the Latin-1 supplement
20//!   0xA0-0xFF; the name lands in ANSI game files). Empty is valid, the
21//!   server autogenerates a snowboarding word pair for it.
22//!
23//! Unknown tags are rejected by `ModUi::validate` / `ModConfig::validate`
24//! (parse liberally, validate strictly, same mechanism as unknown popup
25//! kinds) so a newer mod on an older launcher reports itself instead of
26//! silently skipping the check.
27
28/// The tournament player-name rule: 1 to 9 ASCII letters (a-z, A-Z).
29pub const PLAYER_NAME_TAG: &str = "player_name";
30/// The room display-name rule: up to 24 printable extended-ASCII
31/// characters, empty means the server autogenerates one.
32pub const ROOM_NAME_TAG: &str = "room_name";
33
34/// Every tag this launcher generation knows.
35pub const KNOWN_TAGS: [&str; 2] = [PLAYER_NAME_TAG, ROOM_NAME_TAG];
36
37/// Whether `tag` names a rule set this launcher enforces.
38#[must_use]
39pub fn is_known_tag(tag: &str) -> bool {
40    KNOWN_TAGS.contains(&tag)
41}
42
43/// Validates one value against the rule set `tag` names.
44///
45/// # Errors
46///
47/// Returns the human-readable reason the value was refused, phrased so
48/// it can be shown next to the field as-is. An empty room name is valid
49/// (the server autogenerates one); an empty player name is not.
50pub fn validate_value(tag: &str, value: &str) -> Result<(), String> {
51    match tag {
52        PLAYER_NAME_TAG => validate_player_name(value).map(|_| ()),
53        ROOM_NAME_TAG => validate_room_name(value),
54        other => Err(format!(
55            "unknown validation tag `{other}` (expected one of: {})",
56            KNOWN_TAGS.join(", ")
57        )),
58    }
59}
60
61/// The player-name rule set: trimmed, 1 to 9 ASCII letters. Returns the
62/// trimmed name, or the reason the input was refused.
63///
64/// # Errors
65///
66/// The three refusal reasons: missing, too long, non-letters.
67pub fn validate_player_name(raw: &str) -> Result<String, String> {
68    let name = raw.trim();
69    if name.is_empty() {
70        return Err("Enter a player name: 1 to 9 letters (a-z, A-Z).".to_owned());
71    }
72    if !(1..=9).contains(&name.len()) {
73        return Err("The player name is limited to 9 letters (a-z, A-Z).".to_owned());
74    }
75    if !name.chars().all(|c| c.is_ascii_alphabetic()) {
76        return Err("The player name takes letters only (a-z, A-Z).".to_owned());
77    }
78    Ok(name.to_owned())
79}
80
81/// The room-name rule set: empty is fine (server autogenerates),
82/// otherwise up to 24 printable extended-ASCII characters.
83///
84/// # Errors
85///
86/// The two refusal reasons: too long, characters outside the
87/// Windows-1252 printable range.
88pub fn validate_room_name(name: &str) -> Result<(), String> {
89    if name.is_empty() {
90        return Ok(());
91    }
92    if name.chars().count() > 24 {
93        return Err("The room name is limited to 24 characters.".to_owned());
94    }
95    if name
96        .chars()
97        .any(|c| !matches!(u32::from(c), 0x20..=0x7E | 0xA0..=0xFF))
98    {
99        return Err(
100            "The room name must use extended ASCII only (what a 1999 Windows app displays \
101             without surprises)."
102                .to_owned(),
103        );
104    }
105    Ok(())
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn player_name_accepts_one_to_nine_letters() {
114        // The boundary IS the contract: exactly 9 letters is valid.
115        assert_eq!(validate_value(PLAYER_NAME_TAG, "abcd").map(|_| ()), Ok(()));
116        assert_eq!(validate_value(PLAYER_NAME_TAG, "I").map(|_| ()), Ok(()));
117        assert_eq!(
118            validate_value(PLAYER_NAME_TAG, "SupremeXz").map(|_| ()),
119            Ok(())
120        );
121        assert!(validate_value(PLAYER_NAME_TAG, "SupremeXyz").is_err());
122        assert!(validate_value(PLAYER_NAME_TAG, "").is_err());
123        assert!(validate_value(PLAYER_NAME_TAG, "   ").is_err());
124        assert!(validate_value(PLAYER_NAME_TAG, "player1").is_err());
125        assert!(validate_value(PLAYER_NAME_TAG, "café").is_err());
126        // Trimming is part of the rule, the stored name has no padding.
127        assert_eq!(validate_player_name("  sam ").as_deref(), Ok("sam"));
128    }
129
130    #[test]
131    fn room_name_allows_empty_and_extended_ascii_up_to_24() {
132        assert_eq!(validate_value(ROOM_NAME_TAG, ""), Ok(()));
133        assert_eq!(
134            validate_value(ROOM_NAME_TAG, "Fresh Powder").map(|_| ()),
135            Ok(())
136        );
137        // Latin-1 supplement letters are printable extended ASCII.
138        assert_eq!(
139            validate_value(ROOM_NAME_TAG, "Piste Français").map(|_| ()),
140            Ok(())
141        );
142        // The boundary IS the contract: exactly 24 characters is valid
143        // and 25 is refused, counting characters and not bytes, so
144        // multibyte extended-ASCII names measure like the server's
145        // mb_strlen.
146        let twentyfour = "a".repeat(24);
147        assert_eq!(validate_value(ROOM_NAME_TAG, &twentyfour).map(|_| ()), Ok(()));
148        assert!(validate_value(ROOM_NAME_TAG, &"a".repeat(25)).is_err());
149        assert_eq!(
150            validate_value(ROOM_NAME_TAG, &"é".repeat(24)).map(|_| ()),
151            Ok(())
152        );
153        assert!(validate_value(ROOM_NAME_TAG, &"é".repeat(25)).is_err());
154        // Outside the printable range: above 0xFF, control chars, CJK.
155        assert!(validate_value(ROOM_NAME_TAG, "powder\u{100}").is_err());
156        assert!(validate_value(ROOM_NAME_TAG, "powder\u{7F}").is_err());
157        assert!(validate_value(ROOM_NAME_TAG, "粉末").is_err());
158    }
159
160    #[test]
161    fn unknown_tags_are_refused_with_the_known_list() {
162        let reason = validate_value("datepicker", "x").unwrap_err();
163        assert!(reason.contains("player_name") && reason.contains("room_name"));
164        assert!(is_known_tag(ROOM_NAME_TAG));
165        assert!(!is_known_tag("datepicker"));
166    }
167}