mvdsvstat 0.1.0

Get information from MVDSV servers.
Documentation
//! # mvdsvstat
//! Get information from MVDSV servers

use std::io::{BufRead, Cursor};
use std::result;
use std::time::Duration;

use anyhow::{anyhow as e, Result};
use quake_text::bytestr::to_unicode;
#[cfg(feature = "json")]
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
pub struct MvdsvInfo {
    pub settings: quake_serverinfo::Settings,
    pub clients: Vec<MvdsvClient>,
    pub qtv_stream: Option<QtvStream>,
}

impl MvdsvInfo {
    pub fn try_from_status_response(response: &[u8]) -> Result<Self> {
        // validate header
        let expected_header = vec![255, 255, 255, 255, 110];

        if !response.starts_with(&expected_header) {
            return Err(e!("Invalid header"));
        }

        // parse body
        let body = &response[expected_header.len()..];
        let rows: Vec<Vec<u8>> = Cursor::new(body).split(10).filter_map(|l| l.ok()).collect();

        const MIN_LENGTH: usize = "hostname\\x".len();

        if rows.is_empty() || rows[0].len() < MIN_LENGTH {
            return Err(e!("Invalid body"));
        }

        let settings = quake_serverinfo::Settings::from(rows[0].as_slice());
        let mut qtv_stream = None;
        let mut clients: Vec<MvdsvClient> = vec![];

        for row in rows {
            if row.starts_with(b"qtv ") {
                qtv_stream = QtvStream::try_from(row.as_slice()).ok();
            } else if let Ok(client) = MvdsvClient::try_from(row.as_slice()) {
                clients.push(client);
            }
        }

        Ok(Self {
            settings,
            clients,
            qtv_stream,
        })
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
pub struct MvdsvClient {
    pub id: u32,
    pub name: String,
    pub team: String,
    pub frags: i32,
    pub ping: u32,
    pub time: u32,
    pub top_color: u8,
    pub bottom_color: u8,
    pub skin: String,
    pub is_spectator: bool,
}

impl TryFrom<&[u8]> for MvdsvClient {
    type Error = anyhow::Error;

    fn try_from(bytes: &[u8]) -> result::Result<Self, Self::Error> {
        let parts: Vec<String> = tokenize(to_unicode(bytes).as_str());
        let id: u32 = parts[0].parse()?;
        let mut frags: i32 = parts[1].parse()?;
        let time: u32 = parts[2].parse()?;
        let ping: i32 = parts[3].parse()?;
        let mut name = parts[4].to_string();
        let skin = parts[5].to_string();
        let top_color: u8 = parts[6].parse()?;
        let bottom_color: u8 = parts[7].parse()?;
        let team = parts[8].to_string();
        let is_spectator = ping < 1;

        if is_spectator {
            frags = 0;
            name = name.trim_start_matches("\\s\\").to_string();
        }

        Ok(Self {
            id,
            frags,
            ping: ping.unsigned_abs(),
            time,
            name,
            team,
            skin,
            top_color,
            bottom_color,
            is_spectator,
        })
    }
}

#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
pub struct QtvStream {
    pub id: u32,
    pub name: String,
    pub url: String,
    pub client_count: u32,
}

impl TryFrom<&[u8]> for QtvStream {
    type Error = anyhow::Error;

    fn try_from(bytes: &[u8]) -> result::Result<Self, Self::Error> {
        let parts: Vec<String> = tokenize(to_unicode(bytes).as_str());
        let id: u32 = parts[1].parse()?;
        let name: String = parts[2].to_string();
        let url: String = parts[3].to_string();
        let client_count: u32 = parts[4].parse()?;

        Ok(Self {
            id,
            name,
            url,
            client_count,
        })
    }
}

/// Get server info from given MVDSV address
pub fn info(address: &str, timeout: Option<Duration>) -> Result<MvdsvInfo> {
    let response = {
        let message = b"\xff\xff\xff\xffstatus 55".to_vec();
        let options = tinyudp::ReadOptions {
            timeout,
            ..Default::default()
        };
        tinyudp::send_and_read(address, &message, &options)?
    };

    MvdsvInfo::try_from_status_response(response.as_slice())
}

fn tokenize(value: &str) -> Vec<String> {
    let mut tokens: Vec<String> = vec![];
    let mut in_quote = false;
    let mut current_token = "".to_string();

    for c in value.chars() {
        match c {
            '"' => in_quote = !in_quote,
            ' ' => {
                if in_quote {
                    current_token.push(c);
                } else {
                    tokens.push(current_token);
                    current_token = "".to_string();
                }
            }
            _ => current_token.push(c),
        }
    }

    if !current_token.is_empty() {
        tokens.push(current_token);
    }

    tokens
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;

    use super::*;

    #[test]
    fn test_tokenize() {
        assert_eq!(
            tokenize(r#"qtv 1 "zasadzka Qtv (2)" "2@zasadzka.pl:28000" 2"#),
            vec![
                "qtv".to_string(),
                "1".to_string(),
                "zasadzka Qtv (2)".to_string(),
                "2@zasadzka.pl:28000".to_string(),
                "2".to_string()
            ]
        )
    }

    #[test]
    fn test_server_info() -> Result<()> {
        {
            let info = info("quake.se:28501", None)?;
            assert_eq!(
                info.settings.hostname,
                Some("QUAKE.SE KTX:28501".to_string())
            );
        }
        {
            let info = info("foo.bar:666", Some(Duration::from_millis(50)));
            assert!(info.is_err());
        }
        Ok(())
    }

    #[test]
    fn test_mvdsv_info() -> Result<()> {
        // invalid
        {
            let result = MvdsvInfo::try_from_status_response([0].as_slice());
            assert_eq!(
                result.unwrap_err().to_string(),
                "Invalid header".to_string()
            );
        }
        {
            let result =
                MvdsvInfo::try_from_status_response([255, 255, 255, 255, 110, 0].as_slice());
            assert_eq!(result.unwrap_err().to_string(), "Invalid body".to_string());
        }

        // with clients
        {
            let response = [
                255, 255, 255, 255, 110, 92, 109, 97, 120, 102, 112, 115, 92, 55, 55, 92, 112, 109,
                95, 107, 116, 106, 117, 109, 112, 92, 49, 92, 42, 118, 101, 114, 115, 105, 111,
                110, 92, 77, 86, 68, 83, 86, 32, 48, 46, 51, 54, 92, 42, 122, 95, 101, 120, 116,
                92, 53, 49, 49, 92, 42, 97, 100, 109, 105, 110, 92, 108, 111, 108, 101, 107, 32,
                60, 108, 111, 108, 101, 107, 64, 113, 117, 97, 107, 101, 49, 46, 112, 108, 62, 92,
                107, 116, 120, 118, 101, 114, 92, 49, 46, 52, 50, 92, 115, 118, 95, 97, 110, 116,
                105, 108, 97, 103, 92, 50, 92, 110, 101, 101, 100, 112, 97, 115, 115, 92, 52, 92,
                109, 97, 120, 115, 112, 101, 99, 116, 97, 116, 111, 114, 115, 92, 49, 50, 92, 42,
                103, 97, 109, 101, 100, 105, 114, 92, 113, 119, 92, 116, 101, 97, 109, 112, 108,
                97, 121, 92, 50, 92, 109, 111, 100, 101, 92, 50, 111, 110, 50, 92, 116, 105, 109,
                101, 108, 105, 109, 105, 116, 92, 49, 48, 92, 100, 101, 97, 116, 104, 109, 97, 116,
                99, 104, 92, 51, 92, 42, 113, 118, 109, 92, 115, 111, 92, 42, 112, 114, 111, 103,
                115, 92, 115, 111, 92, 109, 97, 120, 99, 108, 105, 101, 110, 116, 115, 92, 52, 92,
                109, 97, 112, 92, 122, 116, 110, 100, 109, 51, 92, 115, 101, 114, 118, 101, 114,
                100, 101, 109, 111, 92, 50, 111, 110, 50, 95, 114, 101, 100, 95, 118, 115, 95, 98,
                108, 117, 101, 91, 122, 116, 110, 100, 109, 51, 93, 50, 48, 50, 52, 48, 55, 49, 54,
                45, 49, 50, 52, 52, 46, 109, 118, 100, 92, 104, 111, 115, 116, 110, 97, 109, 101,
                92, 122, 97, 115, 97, 100, 122, 107, 97, 58, 50, 55, 53, 48, 49, 32, 40, 114, 101,
                100, 32, 118, 115, 46, 32, 98, 108, 117, 101, 41, 135, 92, 102, 112, 100, 92, 50,
                48, 54, 92, 115, 116, 97, 116, 117, 115, 92, 57, 32, 109, 105, 110, 32, 108, 101,
                102, 116, 10, 55, 53, 32, 49, 49, 32, 50, 32, 50, 53, 32, 34, 244, 105, 97, 108,
                108, 34, 32, 34, 34, 32, 52, 32, 52, 32, 34, 114, 101, 100, 34, 10, 56, 48, 32, 50,
                32, 50, 32, 49, 51, 32, 34, 114, 105, 107, 105, 34, 32, 34, 34, 32, 49, 51, 32, 49,
                51, 32, 34, 98, 108, 117, 101, 34, 10, 56, 52, 32, 52, 32, 50, 32, 53, 49, 32, 34,
                78, 76, 34, 32, 34, 34, 32, 52, 32, 52, 32, 34, 114, 101, 100, 34, 10, 55, 56, 32,
                45, 57, 57, 57, 57, 32, 50, 32, 45, 53, 54, 32, 34, 92, 115, 92, 98, 97, 100, 97,
                115, 115, 34, 32, 34, 98, 97, 100, 97, 115, 115, 34, 32, 49, 48, 32, 49, 49, 32,
                34, 109, 97, 122, 34, 10, 55, 57, 32, 45, 57, 57, 57, 57, 32, 50, 32, 45, 51, 56,
                32, 34, 92, 115, 92, 108, 111, 107, 101, 34, 32, 34, 34, 32, 52, 32, 52, 32, 34,
                114, 101, 100, 34, 10, 56, 49, 32, 45, 57, 57, 57, 57, 32, 50, 32, 45, 51, 56, 32,
                34, 92, 115, 92, 81, 117, 97, 107, 101, 34, 32, 34, 34, 32, 49, 51, 32, 49, 51, 32,
                34, 98, 108, 117, 101, 34, 10, 56, 53, 32, 51, 32, 50, 32, 52, 53, 32, 34, 72, 108,
                89, 34, 32, 34, 34, 32, 49, 51, 32, 49, 51, 32, 34, 98, 108, 117, 101, 34, 10, 56,
                54, 32, 45, 57, 57, 57, 57, 32, 50, 32, 45, 54, 54, 54, 32, 34, 92, 115, 92, 91,
                83, 101, 114, 118, 101, 77, 101, 93, 34, 32, 34, 34, 32, 49, 50, 32, 49, 49, 32,
                34, 108, 113, 119, 99, 34, 10, 113, 116, 118, 32, 49, 32, 34, 122, 97, 115, 97,
                100, 122, 107, 97, 32, 81, 116, 118, 32, 40, 50, 41, 34, 32, 34, 50, 64, 122, 97,
                115, 97, 100, 122, 107, 97, 46, 112, 108, 58, 50, 56, 48, 48, 48, 34, 32, 50, 10,
                0,
            ]
            .as_slice();

            let info = MvdsvInfo::try_from_status_response(response)?;
            assert_eq!(
                info.settings.hostname,
                Some("zasadzka:27501 (red vs. blue)\u{87}".to_string())
            );

            assert_eq!(
                info.qtv_stream,
                Some(QtvStream {
                    id: 1,
                    name: "zasadzka Qtv (2)".to_string(),
                    url: "2@zasadzka.pl:28000".to_string(),
                    client_count: 2,
                })
            );

            assert_eq!(
                info.clients,
                vec![
                    MvdsvClient {
                        id: 75,
                        frags: 11,
                        ping: 25,
                        time: 2,
                        name: "ôiall".to_string(),
                        team: "red".to_string(),
                        skin: "".to_string(),
                        top_color: 4,
                        bottom_color: 4,
                        is_spectator: false,
                    },
                    MvdsvClient {
                        id: 80,
                        frags: 2,
                        ping: 13,
                        time: 2,
                        name: "riki".to_string(),
                        team: "blue".to_string(),
                        skin: "".to_string(),
                        top_color: 13,
                        bottom_color: 13,
                        is_spectator: false,
                    },
                    MvdsvClient {
                        id: 84,
                        frags: 4,
                        ping: 51,
                        time: 2,
                        name: "NL".to_string(),
                        team: "red".to_string(),
                        skin: "".to_string(),
                        top_color: 4,
                        bottom_color: 4,
                        is_spectator: false,
                    },
                    MvdsvClient {
                        id: 78,
                        frags: 0,
                        ping: 56,
                        time: 2,
                        name: "badass".to_string(),
                        team: "maz".to_string(),
                        skin: "badass".to_string(),
                        top_color: 10,
                        bottom_color: 11,
                        is_spectator: true,
                    },
                    MvdsvClient {
                        id: 79,
                        frags: 0,
                        ping: 38,
                        time: 2,
                        name: "loke".to_string(),
                        team: "red".to_string(),
                        skin: "".to_string(),
                        top_color: 4,
                        bottom_color: 4,
                        is_spectator: true,
                    },
                    MvdsvClient {
                        id: 81,
                        frags: 0,
                        ping: 38,
                        time: 2,
                        name: "Quake".to_string(),
                        team: "blue".to_string(),
                        skin: "".to_string(),
                        top_color: 13,
                        bottom_color: 13,
                        is_spectator: true,
                    },
                    MvdsvClient {
                        id: 85,
                        frags: 3,
                        ping: 45,
                        time: 2,
                        name: "HlY".to_string(),
                        team: "blue".to_string(),
                        skin: "".to_string(),
                        top_color: 13,
                        bottom_color: 13,
                        is_spectator: false,
                    },
                    MvdsvClient {
                        id: 86,
                        frags: 0,
                        ping: 666,
                        time: 2,
                        name: "[ServeMe]".to_string(),
                        team: "lqwc".to_string(),
                        skin: "".to_string(),
                        top_color: 12,
                        bottom_color: 11,
                        is_spectator: true,
                    },
                ]
            );
        }

        Ok(())
    }
}