From a036e7eb46886ff7fe88255ae26c327f63ec4bfa Mon Sep 17 00:00:00 2001 From: salmonik Date: Sun, 1 Nov 2026 16:26:21 +0100 Subject: v3.3.0: keep client.db in the XDG data dir, add -d/--data-dir --- Cargo.toml | 2 +- docs/SERVER_SETUP.md | 4 +- docs/TESTING.md | 12 ++++-- src/bin/client/main.rs | 35 ++++++++++++++--- src/bin/client/repl.rs | 12 +++--- src/client/mod.rs | 1 + src/client/paths.rs | 103 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/client_paths.rs | 95 +++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 246 insertions(+), 18 deletions(-) create mode 100644 src/client/paths.rs create mode 100644 tests/client_paths.rs diff --git a/Cargo.toml b/Cargo.toml index 8bf04dc..66613a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zagan-bet" -version = "3.2.0" +version = "3.3.0" edition = "2024" [features] diff --git a/docs/SERVER_SETUP.md b/docs/SERVER_SETUP.md index 9eac7a7..700a12e 100644 --- a/docs/SERVER_SETUP.md +++ b/docs/SERVER_SETUP.md @@ -79,7 +79,9 @@ last address for that key is forgotten. The server creates `server.db` in the current directory on first use. It holds users, balances, and the per-game `plays` audit. The client keeps its own -`client.db` for host fingerprints and stored credentials. There are no schema +`client.db` for host fingerprints and stored credentials in +`$XDG_DATA_HOME/zagan-bet/` (usually `~/.local/share/zagan-bet/`); pass +`-d ` to keep it somewhere else. There are no schema migrations while the app is in alpha: a schema change means deleting both files. During development, delete both files to reset users, balances, history, and diff --git a/docs/TESTING.md b/docs/TESTING.md index ac07a3f..1a1d369 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -52,12 +52,16 @@ on a machine where the `openssl` CLI is available. ## Development resets -The server creates `server.db` in its working directory; the client creates -`client.db` for host trust and stored credentials. While the software is in -testing, delete both after a schema or trust change: +The server creates `server.db` in its working directory. The client keeps +`client.db` (host trust, stored credentials) in the XDG data directory: +`$XDG_DATA_HOME/zagan-bet/client.db`, usually +`~/.local/share/zagan-bet/client.db`. Override it with `client -d `. + +While the software is in testing, delete both after a schema or trust change: ```sh -rm -f server.db client.db +rm -f server.db +rm -rf ~/.local/share/zagan-bet ``` The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` diff --git a/src/bin/client/main.rs b/src/bin/client/main.rs index 9018026..3407380 100644 --- a/src/bin/client/main.rs +++ b/src/bin/client/main.rs @@ -5,10 +5,21 @@ mod repl; mod tls; use repl::ClientState; +use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE}; fn main() { - let args: Vec = std::env::args().collect(); - let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); + let args = match parse_args(std::env::args().skip(1)) { + Ok(args) => args, + Err(message) => { + eprintln!("{message}"); + std::process::exit(2); + } + }; + + if args.help { + println!("{USAGE}"); + return; + } // Every game board is drawn in place, so refuse a terminal that cannot fit // them before touching the network or the terminal mode. @@ -25,19 +36,33 @@ fn main() { std::process::exit(1); } - if unsafe_mode { + if args.unsafe_mode { println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); } - connection::set_unsafe_mode(unsafe_mode); + connection::set_unsafe_mode(args.unsafe_mode); - let mut state = ClientState::new(); + let data_dir = args.data_dir.unwrap_or_else(default_data_dir); + if let Err(error) = std::fs::create_dir_all(&data_dir) { + eprintln!("Cannot create {}: {error}", data_dir.display()); + std::process::exit(1); + } + let db_path = database_path(&data_dir); + + let mut state = match ClientState::new(&db_path) { + Ok(state) => state, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; if let Some(host) = state.addr.clone() { state.connect_to(&host); } println!("Welcome to Zagan-Bet Client!"); println!("Type 'help' for available commands."); + println!("Data: {}", db_path.display()); state.run(); } diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs index bf02c8d..1debff1 100644 --- a/src/bin/client/repl.rs +++ b/src/bin/client/repl.rs @@ -43,11 +43,9 @@ pub struct ClientState { } impl ClientState { - pub fn new() -> Self { - let conn = rusqlite::Connection::open( - std::env::var("CLIENT_DB").unwrap_or_else(|_| "client.db".to_string()), - ) - .unwrap(); + pub fn new(db_path: &std::path::Path) -> Result { + let conn = rusqlite::Connection::open(db_path) + .map_err(|error| format!("cannot open {}: {error}", db_path.display()))?; database::init_db(&conn); let default_ip = "127.0.0.1".to_string(); let default_port = 11525; @@ -55,7 +53,7 @@ impl ClientState { let default_host = format!("{}:{}", default_ip, default_port); let addr = last_host.or(Some(default_host)); - Self { + Ok(Self { stream: None, conn, addr, @@ -66,7 +64,7 @@ impl ClientState { prompt_text: String::new(), terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), size_warned: None, - } + }) } pub fn connect_to(&mut self, addr: &str) { diff --git a/src/client/mod.rs b/src/client/mod.rs index dc8423b..6841be3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,4 +1,5 @@ pub mod core; pub mod database; pub mod games; +pub mod paths; pub mod ui; diff --git a/src/client/paths.rs b/src/client/paths.rs new file mode 100644 index 0000000..b6b8d1f --- /dev/null +++ b/src/client/paths.rs @@ -0,0 +1,103 @@ +//! Client data location and command-line arguments. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +pub const APP_DIR: &str = "zagan-bet"; +pub const DATABASE_FILE: &str = "client.db"; + +pub const USAGE: &str = "\ +usage: client [-d ] [--unsafe] + + -d, --data-dir where to keep client.db (default: XDG data dir) + --unsafe skip certificate verification + -h, --help show this message + +The database lives in $XDG_DATA_HOME/zagan-bet/client.db, falling back to +~/.local/share/zagan-bet/client.db (or %APPDATA%\\zagan-bet on Windows)."; + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct ClientArgs { + pub unsafe_mode: bool, + pub data_dir: Option, + pub help: bool, +} + +/// Parse arguments, without the program name. +pub fn parse_args(args: impl IntoIterator) -> Result { + let mut parsed = ClientArgs::default(); + let mut args = args.into_iter(); + + while let Some(argument) = args.next() { + match argument.as_str() { + "-d" | "--data-dir" => { + let Some(value) = args.next() else { + return Err(format!("{argument} needs a directory\n{USAGE}")); + }; + if value.is_empty() { + return Err(format!("{argument} needs a directory\n{USAGE}")); + } + parsed.data_dir = Some(PathBuf::from(value)); + } + "--unsafe" => parsed.unsafe_mode = true, + "-h" | "--help" => parsed.help = true, + _ => { + if let Some(value) = argument.strip_prefix("--data-dir=") { + if value.is_empty() { + return Err(format!("--data-dir needs a directory\n{USAGE}")); + } + parsed.data_dir = Some(PathBuf::from(value)); + } else { + return Err(format!("unknown argument: {argument}\n{USAGE}")); + } + } + } + } + + Ok(parsed) +} + +/// `$XDG_DATA_HOME/zagan-bet`, `%APPDATA%\zagan-bet`, or +/// `$HOME/.local/share/zagan-bet`, in that order. +pub fn data_dir_from( + xdg_data_home: Option<&OsStr>, + home: Option<&OsStr>, + appdata: Option<&OsStr>, +) -> PathBuf { + if let Some(xdg) = xdg_data_home { + let path = Path::new(xdg); + // The XDG spec says a relative XDG_DATA_HOME is invalid. + if !xdg.is_empty() && path.is_absolute() { + return path.join(APP_DIR); + } + } + + if let Some(appdata) = appdata { + let path = Path::new(appdata); + if !appdata.is_empty() && path.is_absolute() { + return path.join(APP_DIR); + } + } + + if let Some(home) = home { + let path = Path::new(home); + if !home.is_empty() && path.is_absolute() { + return path.join(".local").join("share").join(APP_DIR); + } + } + + // No usable home directory: keep the old behaviour and stay local. + PathBuf::from(APP_DIR) +} + +pub fn default_data_dir() -> PathBuf { + data_dir_from( + std::env::var_os("XDG_DATA_HOME").as_deref(), + std::env::var_os("HOME").as_deref(), + std::env::var_os("APPDATA").as_deref(), + ) +} + +pub fn database_path(data_dir: &Path) -> PathBuf { + data_dir.join(DATABASE_FILE) +} diff --git a/tests/client_paths.rs b/tests/client_paths.rs new file mode 100644 index 0000000..0e45a8d --- /dev/null +++ b/tests/client_paths.rs @@ -0,0 +1,95 @@ +use std::ffi::OsStr; +use std::path::PathBuf; + +use zagan_bet::client::paths::{data_dir_from, database_path, parse_args, APP_DIR, DATABASE_FILE}; + +fn args(values: &[&str]) -> Result { + parse_args(values.iter().map(|value| value.to_string())) +} + +#[test] +fn the_data_directory_follows_the_xdg_spec() { + let xdg = OsStr::new("/home/user/.local/share"); + let home = OsStr::new("/home/user"); + assert_eq!( + data_dir_from(Some(xdg), Some(home), None), + PathBuf::from("/home/user/.local/share").join(APP_DIR) + ); + + // A relative XDG_DATA_HOME is invalid and must be ignored. + assert_eq!( + data_dir_from(Some(OsStr::new("relative/path")), Some(home), None), + PathBuf::from("/home/user/.local/share").join(APP_DIR) + ); + + // No XDG: HOME/.local/share. + assert_eq!( + data_dir_from(None, Some(home), None), + PathBuf::from("/home/user/.local/share").join(APP_DIR) + ); + + // Windows: APPDATA wins over HOME when XDG is unset. + assert_eq!( + data_dir_from(None, Some(home), Some(OsStr::new("/home/user/AppData/Roaming"))), + PathBuf::from("/home/user/AppData/Roaming").join(APP_DIR) + ); + + // No usable home at all: stay relative rather than guessing. + assert_eq!( + data_dir_from(Some(OsStr::new("")), Some(OsStr::new("")), Some(OsStr::new(""))), + PathBuf::from(APP_DIR) + ); +} + +#[test] +fn the_database_lives_inside_the_data_directory() { + assert_eq!( + database_path(&PathBuf::from("/tmp/zagan")), + PathBuf::from("/tmp/zagan").join(DATABASE_FILE) + ); +} + +#[test] +fn dash_d_sets_the_data_directory() { + let parsed = args(&["-d", "/tmp/zagan-data"]).unwrap(); + assert_eq!( + parsed.data_dir, + Some(PathBuf::from("/tmp/zagan-data")) + ); + assert!(!parsed.unsafe_mode); + assert!(!parsed.help); + + let parsed = args(&["--data-dir", "/var/lib/zagan"]).unwrap(); + assert_eq!(parsed.data_dir, Some(PathBuf::from("/var/lib/zagan"))); + + let parsed = args(&["--data-dir=/var/lib/zagan"]).unwrap(); + assert_eq!(parsed.data_dir, Some(PathBuf::from("/var/lib/zagan"))); +} + +#[test] +fn flags_can_be_combined() { + let parsed = args(&["--unsafe", "-d", "/tmp/x"]).unwrap(); + assert!(parsed.unsafe_mode); + assert_eq!(parsed.data_dir, Some(PathBuf::from("/tmp/x"))); +} + +#[test] +fn bad_arguments_are_rejected_with_usage() { + let error = args(&["-d"]).unwrap_err(); + assert!(error.contains("needs a directory"), "{error}"); + assert!(error.contains("usage:"), "{error}"); + + let error = args(&["--data-dir="]).unwrap_err(); + assert!(error.contains("needs a directory"), "{error}"); + + let error = args(&["--nope"]).unwrap_err(); + assert!(error.contains("unknown argument: --nope"), "{error}"); +} + +#[test] +fn help_is_a_flag_not_an_error() { + let parsed = args(&["-h"]).unwrap(); + assert!(parsed.help); + let parsed = args(&["--help"]).unwrap(); + assert!(parsed.help); +} -- cgit v1.2.3