use crate::error::{Error, Result};
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct GsiCfg {
pub service_name: String,
pub uri: String,
pub timeout: f32,
pub buffer: f32,
pub throttle: f32,
pub heartbeat: f32,
pub auth: BTreeMap<String, String>,
pub data: BTreeMap<String, String>,
}
impl GsiCfg {
pub fn for_localhost(service_name: impl Into<String>, port: u16) -> Self {
Self {
service_name: service_name.into(),
uri: format!("http://localhost:{port}/"),
timeout: 5.0,
buffer: 0.1,
throttle: 0.1,
heartbeat: 10.0,
auth: BTreeMap::new(),
data: default_data_sections(),
}
}
pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
self.uri = uri.into();
self
}
pub fn with_auth(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.auth.insert(key.into(), value.into());
self
}
pub fn without_section(mut self, name: &str) -> Self {
self.data.remove(name);
self
}
pub fn render(&self) -> String {
let mut out = String::with_capacity(512);
let _ = writeln!(out, "\"{} Integration Configuration\"", self.service_name);
let _ = writeln!(out, "{{");
let _ = writeln!(out, " \"uri\" \"{}\"", self.uri);
let _ = writeln!(out, " \"timeout\" \"{:.1}\"", self.timeout);
let _ = writeln!(out, " \"buffer\" \"{:.1}\"", self.buffer);
let _ = writeln!(out, " \"throttle\" \"{:.1}\"", self.throttle);
let _ = writeln!(out, " \"heartbeat\" \"{:.1}\"", self.heartbeat);
if !self.auth.is_empty() {
let _ = writeln!(out, " \"auth\"");
let _ = writeln!(out, " {{");
for (k, v) in &self.auth {
let _ = writeln!(out, " \"{k}\" \"{v}\"");
}
let _ = writeln!(out, " }}");
}
let _ = writeln!(out, " \"data\"");
let _ = writeln!(out, " {{");
for (k, v) in &self.data {
let quoted = format!("\"{k}\"");
let _ = writeln!(out, " {quoted:<26} \"{v}\"");
}
let _ = writeln!(out, " }}");
let _ = writeln!(out, "}}");
out
}
pub fn file_name(&self) -> PathBuf {
let slug: String = self
.service_name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
PathBuf::from(format!("gamestate_integration_{slug}.cfg"))
}
pub fn write_to_cfg_dir(&self, cfg_dir: &Path) -> Result<PathBuf> {
std::fs::create_dir_all(cfg_dir).map_err(|source| Error::CfgWrite {
path: cfg_dir.to_path_buf(),
source,
})?;
let path = cfg_dir.join(self.file_name());
std::fs::write(&path, self.render()).map_err(|source| Error::CfgWrite {
path: path.clone(),
source,
})?;
Ok(path)
}
#[cfg(feature = "steam-discover")]
pub fn write_to_cs2(&self) -> Result<PathBuf> {
let cfg_dir = crate::steam::find_cs2_cfg_dir()?;
self.write_to_cfg_dir(&cfg_dir)
}
}
fn default_data_sections() -> BTreeMap<String, String> {
let mut m = BTreeMap::new();
for k in [
"provider",
"tournamentdraft",
"map",
"map_round_wins",
"round",
"player_id",
"player_state",
"player_weapons",
"player_match_stats",
"player_position",
"phase_countdowns",
"allplayers_id",
"allplayers_state",
"allplayers_match_stats",
"allplayers_weapons",
"allplayers_position",
"allgrenades",
"bomb",
] {
m.insert(k.into(), "1".into());
}
m
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_produces_expected_skeleton() {
let cfg = GsiCfg::for_localhost("Demo", 4000);
let rendered = cfg.render();
assert!(rendered.starts_with("\"Demo Integration Configuration\""));
assert!(rendered.contains("\"uri\" \"http://localhost:4000/\""));
assert!(rendered.contains("\"throttle\" \"0.1\""));
assert!(rendered.contains("\"heartbeat\" \"10.0\""));
assert_eq!(rendered.matches("\"1\"").count(), 18);
}
#[test]
fn data_keys_have_no_trailing_whitespace_inside_quotes() {
let cfg = GsiCfg::for_localhost("Demo", 4000);
let rendered = cfg.render();
for key in [
"provider",
"map",
"round",
"bomb",
"allgrenades",
"allplayers_id",
"allplayers_match_stats",
"allplayers_position",
"allplayers_state",
"allplayers_weapons",
"map_round_wins",
"phase_countdowns",
"player_id",
"player_match_stats",
"player_position",
"player_state",
"player_weapons",
"tournamentdraft",
] {
assert!(
rendered.contains(&format!("\"{key}\"")),
"expected exact `\"{key}\"` token in rendered cfg",
);
assert!(
!rendered.contains(&format!("{key} ")) || !rendered.contains(&format!("{key}\"")),
"found a quoted key with internal trailing whitespace",
);
}
}
#[test]
fn file_name_slugifies_service() {
let cfg = GsiCfg::for_localhost("ImLag Rust", 1234);
assert_eq!(
cfg.file_name(),
PathBuf::from("gamestate_integration_ImLag_Rust.cfg")
);
}
#[test]
fn auth_block_is_emitted_when_provided() {
let cfg = GsiCfg::for_localhost("Demo", 4000).with_auth("token", "abc123");
let rendered = cfg.render();
assert!(rendered.contains("\"auth\""));
assert!(rendered.contains("\"token\" \"abc123\""));
}
}