//! Shared parser, fixture, and JSONL helpers for the scripted peer harness. #![allow(clippy::missing_errors_doc)] use std::{ net::SocketAddr, path::{Path, PathBuf}, time::Duration, }; use eyre::{Context, OptionExt}; use lanspread_peer::{UnpackFuture, Unpacker}; use serde::Serialize; use serde_json::{Value, json}; pub const DEFAULT_FIXTURE_VERSION: &str = "20250101"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandEnvelope { pub request_id: Option, pub command: CliCommand, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum CliCommand { Status, ListPeers, ListGames, SetGameDir { path: PathBuf, }, Download { game_id: String, install_after_download: bool, }, Install { game_id: String, }, Uninstall { game_id: String, }, WaitPeers { count: usize, timeout: Duration, }, Connect { addr: SocketAddr, }, Shutdown, } impl CliCommand { #[must_use] pub fn name(&self) -> &'static str { match self { Self::Status => "status", Self::ListPeers => "list-peers", Self::ListGames => "list-games", Self::SetGameDir { .. } => "set-game-dir", Self::Download { .. } => "download", Self::Install { .. } => "install", Self::Uninstall { .. } => "uninstall", Self::WaitPeers { .. } => "wait-peers", Self::Connect { .. } => "connect", Self::Shutdown => "shutdown", } } } pub fn parse_command_line(line: &str) -> eyre::Result { let value = serde_json::from_str::(line).wrap_err("command must be a JSON object")?; parse_command_value(&value) } pub fn parse_command_value(value: &Value) -> eyre::Result { let object = value .as_object() .ok_or_eyre("command must be a JSON object")?; let request_id = object.get("id").cloned(); let command_name = object .get("cmd") .or_else(|| object.get("command")) .and_then(Value::as_str) .ok_or_eyre("command object must include a string cmd")?; let command_name = command_name.replace('_', "-"); let command = match command_name.as_str() { "status" => CliCommand::Status, "list-peers" => CliCommand::ListPeers, "list-games" => CliCommand::ListGames, "set-game-dir" => CliCommand::SetGameDir { path: PathBuf::from(required_str(object, "path")?), }, "download" => CliCommand::Download { game_id: game_id(object)?, install_after_download: install_after_download(object)?, }, "install" => CliCommand::Install { game_id: game_id(object)?, }, "uninstall" => CliCommand::Uninstall { game_id: game_id(object)?, }, "wait-peers" => CliCommand::WaitPeers { count: required_u64(object, "count")? .try_into() .wrap_err("count does not fit in usize")?, timeout: Duration::from_millis(required_u64(object, "timeout_ms")?), }, "connect" | "direct-connect" => CliCommand::Connect { addr: required_str(object, "addr")? .parse() .wrap_err("addr must be a socket address like 127.0.0.1:12345")?, }, "shutdown" => CliCommand::Shutdown, other => eyre::bail!("unknown command: {other}"), }; Ok(CommandEnvelope { request_id, command, }) } fn required_str( object: &serde_json::Map, field: &'static str, ) -> eyre::Result { object .get(field) .and_then(Value::as_str) .map(ToOwned::to_owned) .ok_or_else(|| eyre::eyre!("missing string field {field}")) } fn required_u64(object: &serde_json::Map, field: &'static str) -> eyre::Result { object .get(field) .and_then(Value::as_u64) .ok_or_else(|| eyre::eyre!("missing integer field {field}")) } fn game_id(object: &serde_json::Map) -> eyre::Result { object .get("game_id") .or_else(|| object.get("game")) .and_then(Value::as_str) .map(ToOwned::to_owned) .ok_or_eyre("game commands must include string game_id") } fn install_after_download(object: &serde_json::Map) -> eyre::Result { if let Some(value) = object.get("install") { return value .as_bool() .ok_or_eyre("install must be boolean when provided"); } if let Some(value) = object.get("no_install") { return value .as_bool() .map(|no_install| !no_install) .ok_or_eyre("no_install must be boolean when provided"); } Ok(true) } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct FixtureSeed { pub game_id: String, pub game_root: PathBuf, pub archive: PathBuf, } pub fn seed_fixture_game(game_dir: &Path, fixture_name: &str) -> eyre::Result { let game_id = fixture_name.to_string(); let game_root = game_dir.join(&game_id); std::fs::create_dir_all(&game_root)?; let version_path = game_root.join("version.ini"); std::fs::write(version_path, DEFAULT_FIXTURE_VERSION.as_bytes())?; let archive = game_root.join(format!("{game_id}.eti")); std::fs::write( &archive, format!("fixture archive for {game_id}\n").as_bytes(), )?; Ok(FixtureSeed { game_id, game_root, archive, }) } pub struct FixtureUnpacker; impl Unpacker for FixtureUnpacker { fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { Box::pin(async move { tokio::fs::create_dir_all(dest).await?; let payload = tokio::fs::read(archive).await?; tokio::fs::write(dest.join("fixture-payload.txt"), payload).await?; Ok(()) }) } } pub struct ExternalUnrarUnpacker { program: PathBuf, } impl ExternalUnrarUnpacker { #[must_use] pub fn new(program: PathBuf) -> Self { Self { program } } } impl Unpacker for ExternalUnrarUnpacker { fn unpack<'a>(&'a self, archive: &'a Path, dest: &'a Path) -> UnpackFuture<'a> { Box::pin(async move { tokio::fs::create_dir_all(dest).await?; let status = tokio::process::Command::new(&self.program) .arg("x") .arg("-o+") .arg(archive) .arg(dest) .status() .await?; if !status.success() { eyre::bail!( "unrar failed for {} with status {status}", archive.display() ); } Ok(()) }) } } pub fn result_line(id: &Option, command: &str, data: Value) -> eyre::Result { output_line(json!({ "type": "result", "id": id, "command": command, "ok": true, "data": data, })) } pub fn error_line(id: &Option, command: Option<&str>, error: &str) -> eyre::Result { output_line(json!({ "type": "error", "id": id, "command": command, "error": error, })) } pub fn event_line(event: &str, data: Value) -> eyre::Result { output_line(json!({ "type": "event", "event": event, "data": data, })) } fn output_line(value: Value) -> eyre::Result { serde_json::to_string(&value).wrap_err("failed to serialize JSONL output") } #[cfg(test)] mod tests { use std::sync::Arc; use super::*; struct TempDir(PathBuf); impl TempDir { fn new(name: &str) -> Self { let path = std::env::temp_dir().join(format!( "{name}-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("system clock should be after unix epoch") .as_nanos() )); std::fs::create_dir_all(&path).expect("temp dir should be created"); Self(path) } fn path(&self) -> &Path { &self.0 } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } #[test] fn parses_download_command_with_correlation_id() { let parsed = parse_command_line( r#"{"id":"req-1","cmd":"download","game_id":"fixture-one","install":false}"#, ) .expect("command should parse"); assert_eq!(parsed.request_id, Some(json!("req-1"))); assert_eq!( parsed.command, CliCommand::Download { game_id: "fixture-one".to_string(), install_after_download: false, } ); } #[test] fn formats_result_lines() { let line = result_line(&Some(json!(7)), "status", json!({"peer_count": 0})) .expect("line should serialize"); let parsed = serde_json::from_str::(&line).expect("line should be json"); assert_eq!(parsed["type"], "result"); assert_eq!(parsed["id"], 7); assert_eq!(parsed["data"]["peer_count"], 0); } #[tokio::test] async fn fixture_unpacker_creates_install_payload() { let temp = TempDir::new("lanspread-peer-cli-fixture"); let seed = seed_fixture_game(temp.path(), "fixture-one").expect("fixture should seed"); let dest = temp.path().join("staging"); Arc::new(FixtureUnpacker) .unpack(&seed.archive, &dest) .await .expect("fixture archive should unpack"); let payload = std::fs::read_to_string(dest.join("fixture-payload.txt")) .expect("payload should be written"); assert!(payload.contains("fixture-one")); } }