diff options
| author | salmonik <salmonik@tuta.io> | 2026-11-01 16:26:21 +0100 |
|---|---|---|
| committer | salmonik <salmonik@tuta.io> | 2026-11-01 16:26:21 +0100 |
| commit | a036e7eb46886ff7fe88255ae26c327f63ec4bfa (patch) | |
| tree | 494911ce0d394041c00eff276632e4f5f48f3fb5 | |
| parent | v3.2.0: straight and split bets anywhere on the felt; add history command (diff) | |
| download | zagan-bet-a036e7eb46886ff7fe88255ae26c327f63ec4bfa.tar.gz zagan-bet-a036e7eb46886ff7fe88255ae26c327f63ec4bfa.zip | |
v3.3.0: keep client.db in the XDG data dir, add -d/--data-dir
| -rw-r--r-- | Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/SERVER_SETUP.md | 4 | ||||
| -rw-r--r-- | docs/TESTING.md | 12 | ||||
| -rw-r--r-- | src/bin/client/main.rs | 35 | ||||
| -rw-r--r-- | src/bin/client/repl.rs | 12 | ||||
| -rw-r--r-- | src/client/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/paths.rs | 103 | ||||
| -rw-r--r-- | tests/client_paths.rs | 95 |
8 files changed, 246 insertions, 18 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | [package] | 1 | [package] |
| 2 | name = "zagan-bet" | 2 | name = "zagan-bet" |
| 3 | version = "3.2.0" | 3 | version = "3.3.0" |
| 4 | edition = "2024" | 4 | edition = "2024" |
| 5 | 5 | ||
| 6 | [features] | 6 | [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. | |||
| 79 | 79 | ||
| 80 | The server creates `server.db` in the current directory on first use. It holds | 80 | The server creates `server.db` in the current directory on first use. It holds |
| 81 | users, balances, and the per-game `plays` audit. The client keeps its own | 81 | users, balances, and the per-game `plays` audit. The client keeps its own |
| 82 | `client.db` for host fingerprints and stored credentials. There are no schema | 82 | `client.db` for host fingerprints and stored credentials in |
| 83 | `$XDG_DATA_HOME/zagan-bet/` (usually `~/.local/share/zagan-bet/`); pass | ||
| 84 | `-d <dir>` to keep it somewhere else. There are no schema | ||
| 83 | migrations while the app is in alpha: a schema change means deleting both files. | 85 | migrations while the app is in alpha: a schema change means deleting both files. |
| 84 | 86 | ||
| 85 | During development, delete both files to reset users, balances, history, and | 87 | 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. | |||
| 52 | 52 | ||
| 53 | ## Development resets | 53 | ## Development resets |
| 54 | 54 | ||
| 55 | The server creates `server.db` in its working directory; the client creates | 55 | The server creates `server.db` in its working directory. The client keeps |
| 56 | `client.db` for host trust and stored credentials. While the software is in | 56 | `client.db` (host trust, stored credentials) in the XDG data directory: |
| 57 | testing, delete both after a schema or trust change: | 57 | `$XDG_DATA_HOME/zagan-bet/client.db`, usually |
| 58 | `~/.local/share/zagan-bet/client.db`. Override it with `client -d <dir>`. | ||
| 59 | |||
| 60 | While the software is in testing, delete both after a schema or trust change: | ||
| 58 | 61 | ||
| 59 | ```sh | 62 | ```sh |
| 60 | rm -f server.db client.db | 63 | rm -f server.db |
| 64 | rm -rf ~/.local/share/zagan-bet | ||
| 61 | ``` | 65 | ``` |
| 62 | 66 | ||
| 63 | The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` | 67 | 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; | |||
| 5 | mod tls; | 5 | mod tls; |
| 6 | 6 | ||
| 7 | use repl::ClientState; | 7 | use repl::ClientState; |
| 8 | use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE}; | ||
| 8 | 9 | ||
| 9 | fn main() { | 10 | fn main() { |
| 10 | let args: Vec<String> = std::env::args().collect(); | 11 | let args = match parse_args(std::env::args().skip(1)) { |
| 11 | let unsafe_mode = args.iter().any(|arg| arg == "--unsafe"); | 12 | Ok(args) => args, |
| 13 | Err(message) => { | ||
| 14 | eprintln!("{message}"); | ||
| 15 | std::process::exit(2); | ||
| 16 | } | ||
| 17 | }; | ||
| 18 | |||
| 19 | if args.help { | ||
| 20 | println!("{USAGE}"); | ||
| 21 | return; | ||
| 22 | } | ||
| 12 | 23 | ||
| 13 | // Every game board is drawn in place, so refuse a terminal that cannot fit | 24 | // Every game board is drawn in place, so refuse a terminal that cannot fit |
| 14 | // them before touching the network or the terminal mode. | 25 | // them before touching the network or the terminal mode. |
| @@ -25,19 +36,33 @@ fn main() { | |||
| 25 | std::process::exit(1); | 36 | std::process::exit(1); |
| 26 | } | 37 | } |
| 27 | 38 | ||
| 28 | if unsafe_mode { | 39 | if args.unsafe_mode { |
| 29 | println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); | 40 | println!("WARNING: Running in UNSAFE mode - certificate verification disabled!"); |
| 30 | } | 41 | } |
| 31 | 42 | ||
| 32 | connection::set_unsafe_mode(unsafe_mode); | 43 | connection::set_unsafe_mode(args.unsafe_mode); |
| 33 | 44 | ||
| 34 | let mut state = ClientState::new(); | 45 | let data_dir = args.data_dir.unwrap_or_else(default_data_dir); |
| 46 | if let Err(error) = std::fs::create_dir_all(&data_dir) { | ||
| 47 | eprintln!("Cannot create {}: {error}", data_dir.display()); | ||
| 48 | std::process::exit(1); | ||
| 49 | } | ||
| 50 | let db_path = database_path(&data_dir); | ||
| 51 | |||
| 52 | let mut state = match ClientState::new(&db_path) { | ||
| 53 | Ok(state) => state, | ||
| 54 | Err(error) => { | ||
| 55 | eprintln!("{error}"); | ||
| 56 | std::process::exit(1); | ||
| 57 | } | ||
| 58 | }; | ||
| 35 | if let Some(host) = state.addr.clone() { | 59 | if let Some(host) = state.addr.clone() { |
| 36 | state.connect_to(&host); | 60 | state.connect_to(&host); |
| 37 | } | 61 | } |
| 38 | 62 | ||
| 39 | println!("Welcome to Zagan-Bet Client!"); | 63 | println!("Welcome to Zagan-Bet Client!"); |
| 40 | println!("Type 'help' for available commands."); | 64 | println!("Type 'help' for available commands."); |
| 65 | println!("Data: {}", db_path.display()); | ||
| 41 | 66 | ||
| 42 | state.run(); | 67 | state.run(); |
| 43 | } | 68 | } |
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 { | |||
| 43 | } | 43 | } |
| 44 | 44 | ||
| 45 | impl ClientState { | 45 | impl ClientState { |
| 46 | pub fn new() -> Self { | 46 | pub fn new(db_path: &std::path::Path) -> Result<Self, String> { |
| 47 | let conn = rusqlite::Connection::open( | 47 | let conn = rusqlite::Connection::open(db_path) |
| 48 | std::env::var("CLIENT_DB").unwrap_or_else(|_| "client.db".to_string()), | 48 | .map_err(|error| format!("cannot open {}: {error}", db_path.display()))?; |
| 49 | ) | ||
| 50 | .unwrap(); | ||
| 51 | database::init_db(&conn); | 49 | database::init_db(&conn); |
| 52 | let default_ip = "127.0.0.1".to_string(); | 50 | let default_ip = "127.0.0.1".to_string(); |
| 53 | let default_port = 11525; | 51 | let default_port = 11525; |
| @@ -55,7 +53,7 @@ impl ClientState { | |||
| 55 | let default_host = format!("{}:{}", default_ip, default_port); | 53 | let default_host = format!("{}:{}", default_ip, default_port); |
| 56 | let addr = last_host.or(Some(default_host)); | 54 | let addr = last_host.or(Some(default_host)); |
| 57 | 55 | ||
| 58 | Self { | 56 | Ok(Self { |
| 59 | stream: None, | 57 | stream: None, |
| 60 | conn, | 58 | conn, |
| 61 | addr, | 59 | addr, |
| @@ -66,7 +64,7 @@ impl ClientState { | |||
| 66 | prompt_text: String::new(), | 64 | prompt_text: String::new(), |
| 67 | terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), | 65 | terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), |
| 68 | size_warned: None, | 66 | size_warned: None, |
| 69 | } | 67 | }) |
| 70 | } | 68 | } |
| 71 | 69 | ||
| 72 | pub fn connect_to(&mut self, addr: &str) { | 70 | 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 @@ | |||
| 1 | pub mod core; | 1 | pub mod core; |
| 2 | pub mod database; | 2 | pub mod database; |
| 3 | pub mod games; | 3 | pub mod games; |
| 4 | pub mod paths; | ||
| 4 | pub mod ui; | 5 | 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 @@ | |||
| 1 | //! Client data location and command-line arguments. | ||
| 2 | |||
| 3 | use std::ffi::OsStr; | ||
| 4 | use std::path::{Path, PathBuf}; | ||
| 5 | |||
| 6 | pub const APP_DIR: &str = "zagan-bet"; | ||
| 7 | pub const DATABASE_FILE: &str = "client.db"; | ||
| 8 | |||
| 9 | pub const USAGE: &str = "\ | ||
| 10 | usage: client [-d <dir>] [--unsafe] | ||
| 11 | |||
| 12 | -d, --data-dir <dir> where to keep client.db (default: XDG data dir) | ||
| 13 | --unsafe skip certificate verification | ||
| 14 | -h, --help show this message | ||
| 15 | |||
| 16 | The database lives in $XDG_DATA_HOME/zagan-bet/client.db, falling back to | ||
| 17 | ~/.local/share/zagan-bet/client.db (or %APPDATA%\\zagan-bet on Windows)."; | ||
| 18 | |||
| 19 | #[derive(Debug, Default, PartialEq, Eq)] | ||
| 20 | pub struct ClientArgs { | ||
| 21 | pub unsafe_mode: bool, | ||
| 22 | pub data_dir: Option<PathBuf>, | ||
| 23 | pub help: bool, | ||
| 24 | } | ||
| 25 | |||
| 26 | /// Parse arguments, without the program name. | ||
| 27 | pub fn parse_args(args: impl IntoIterator<Item = String>) -> Result<ClientArgs, String> { | ||
| 28 | let mut parsed = ClientArgs::default(); | ||
| 29 | let mut args = args.into_iter(); | ||
| 30 | |||
| 31 | while let Some(argument) = args.next() { | ||
| 32 | match argument.as_str() { | ||
| 33 | "-d" | "--data-dir" => { | ||
| 34 | let Some(value) = args.next() else { | ||
| 35 | return Err(format!("{argument} needs a directory\n{USAGE}")); | ||
| 36 | }; | ||
| 37 | if value.is_empty() { | ||
| 38 | return Err(format!("{argument} needs a directory\n{USAGE}")); | ||
| 39 | } | ||
| 40 | parsed.data_dir = Some(PathBuf::from(value)); | ||
| 41 | } | ||
| 42 | "--unsafe" => parsed.unsafe_mode = true, | ||
| 43 | "-h" | "--help" => parsed.help = true, | ||
| 44 | _ => { | ||
| 45 | if let Some(value) = argument.strip_prefix("--data-dir=") { | ||
| 46 | if value.is_empty() { | ||
| 47 | return Err(format!("--data-dir needs a directory\n{USAGE}")); | ||
| 48 | } | ||
| 49 | parsed.data_dir = Some(PathBuf::from(value)); | ||
| 50 | } else { | ||
| 51 | return Err(format!("unknown argument: {argument}\n{USAGE}")); | ||
| 52 | } | ||
| 53 | } | ||
| 54 | } | ||
| 55 | } | ||
| 56 | |||
| 57 | Ok(parsed) | ||
| 58 | } | ||
| 59 | |||
| 60 | /// `$XDG_DATA_HOME/zagan-bet`, `%APPDATA%\zagan-bet`, or | ||
| 61 | /// `$HOME/.local/share/zagan-bet`, in that order. | ||
| 62 | pub fn data_dir_from( | ||
| 63 | xdg_data_home: Option<&OsStr>, | ||
| 64 | home: Option<&OsStr>, | ||
| 65 | appdata: Option<&OsStr>, | ||
| 66 | ) -> PathBuf { | ||
| 67 | if let Some(xdg) = xdg_data_home { | ||
| 68 | let path = Path::new(xdg); | ||
| 69 | // The XDG spec says a relative XDG_DATA_HOME is invalid. | ||
| 70 | if !xdg.is_empty() && path.is_absolute() { | ||
| 71 | return path.join(APP_DIR); | ||
| 72 | } | ||
| 73 | } | ||
| 74 | |||
| 75 | if let Some(appdata) = appdata { | ||
| 76 | let path = Path::new(appdata); | ||
| 77 | if !appdata.is_empty() && path.is_absolute() { | ||
| 78 | return path.join(APP_DIR); | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | if let Some(home) = home { | ||
| 83 | let path = Path::new(home); | ||
| 84 | if !home.is_empty() && path.is_absolute() { | ||
| 85 | return path.join(".local").join("share").join(APP_DIR); | ||
| 86 | } | ||
| 87 | } | ||
| 88 | |||
| 89 | // No usable home directory: keep the old behaviour and stay local. | ||
| 90 | PathBuf::from(APP_DIR) | ||
| 91 | } | ||
| 92 | |||
| 93 | pub fn default_data_dir() -> PathBuf { | ||
| 94 | data_dir_from( | ||
| 95 | std::env::var_os("XDG_DATA_HOME").as_deref(), | ||
| 96 | std::env::var_os("HOME").as_deref(), | ||
| 97 | std::env::var_os("APPDATA").as_deref(), | ||
| 98 | ) | ||
| 99 | } | ||
| 100 | |||
| 101 | pub fn database_path(data_dir: &Path) -> PathBuf { | ||
| 102 | data_dir.join(DATABASE_FILE) | ||
| 103 | } | ||
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 @@ | |||
| 1 | use std::ffi::OsStr; | ||
| 2 | use std::path::PathBuf; | ||
| 3 | |||
| 4 | use zagan_bet::client::paths::{data_dir_from, database_path, parse_args, APP_DIR, DATABASE_FILE}; | ||
| 5 | |||
| 6 | fn args(values: &[&str]) -> Result<zagan_bet::client::paths::ClientArgs, String> { | ||
| 7 | parse_args(values.iter().map(|value| value.to_string())) | ||
| 8 | } | ||
| 9 | |||
| 10 | #[test] | ||
| 11 | fn the_data_directory_follows_the_xdg_spec() { | ||
| 12 | let xdg = OsStr::new("/home/user/.local/share"); | ||
| 13 | let home = OsStr::new("/home/user"); | ||
| 14 | assert_eq!( | ||
| 15 | data_dir_from(Some(xdg), Some(home), None), | ||
| 16 | PathBuf::from("/home/user/.local/share").join(APP_DIR) | ||
| 17 | ); | ||
| 18 | |||
| 19 | // A relative XDG_DATA_HOME is invalid and must be ignored. | ||
| 20 | assert_eq!( | ||
| 21 | data_dir_from(Some(OsStr::new("relative/path")), Some(home), None), | ||
| 22 | PathBuf::from("/home/user/.local/share").join(APP_DIR) | ||
| 23 | ); | ||
| 24 | |||
| 25 | // No XDG: HOME/.local/share. | ||
| 26 | assert_eq!( | ||
| 27 | data_dir_from(None, Some(home), None), | ||
| 28 | PathBuf::from("/home/user/.local/share").join(APP_DIR) | ||
| 29 | ); | ||
| 30 | |||
| 31 | // Windows: APPDATA wins over HOME when XDG is unset. | ||
| 32 | assert_eq!( | ||
| 33 | data_dir_from(None, Some(home), Some(OsStr::new("/home/user/AppData/Roaming"))), | ||
| 34 | PathBuf::from("/home/user/AppData/Roaming").join(APP_DIR) | ||
| 35 | ); | ||
| 36 | |||
| 37 | // No usable home at all: stay relative rather than guessing. | ||
| 38 | assert_eq!( | ||
| 39 | data_dir_from(Some(OsStr::new("")), Some(OsStr::new("")), Some(OsStr::new(""))), | ||
| 40 | PathBuf::from(APP_DIR) | ||
| 41 | ); | ||
| 42 | } | ||
| 43 | |||
| 44 | #[test] | ||
| 45 | fn the_database_lives_inside_the_data_directory() { | ||
| 46 | assert_eq!( | ||
| 47 | database_path(&PathBuf::from("/tmp/zagan")), | ||
| 48 | PathBuf::from("/tmp/zagan").join(DATABASE_FILE) | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | |||
| 52 | #[test] | ||
| 53 | fn dash_d_sets_the_data_directory() { | ||
| 54 | let parsed = args(&["-d", "/tmp/zagan-data"]).unwrap(); | ||
| 55 | assert_eq!( | ||
| 56 | parsed.data_dir, | ||
| 57 | Some(PathBuf::from("/tmp/zagan-data")) | ||
| 58 | ); | ||
| 59 | assert!(!parsed.unsafe_mode); | ||
| 60 | assert!(!parsed.help); | ||
| 61 | |||
| 62 | let parsed = args(&["--data-dir", "/var/lib/zagan"]).unwrap(); | ||
| 63 | assert_eq!(parsed.data_dir, Some(PathBuf::from("/var/lib/zagan"))); | ||
| 64 | |||
| 65 | let parsed = args(&["--data-dir=/var/lib/zagan"]).unwrap(); | ||
| 66 | assert_eq!(parsed.data_dir, Some(PathBuf::from("/var/lib/zagan"))); | ||
| 67 | } | ||
| 68 | |||
| 69 | #[test] | ||
| 70 | fn flags_can_be_combined() { | ||
| 71 | let parsed = args(&["--unsafe", "-d", "/tmp/x"]).unwrap(); | ||
| 72 | assert!(parsed.unsafe_mode); | ||
| 73 | assert_eq!(parsed.data_dir, Some(PathBuf::from("/tmp/x"))); | ||
| 74 | } | ||
| 75 | |||
| 76 | #[test] | ||
| 77 | fn bad_arguments_are_rejected_with_usage() { | ||
| 78 | let error = args(&["-d"]).unwrap_err(); | ||
| 79 | assert!(error.contains("needs a directory"), "{error}"); | ||
| 80 | assert!(error.contains("usage:"), "{error}"); | ||
| 81 | |||
| 82 | let error = args(&["--data-dir="]).unwrap_err(); | ||
| 83 | assert!(error.contains("needs a directory"), "{error}"); | ||
| 84 | |||
| 85 | let error = args(&["--nope"]).unwrap_err(); | ||
| 86 | assert!(error.contains("unknown argument: --nope"), "{error}"); | ||
| 87 | } | ||
| 88 | |||
| 89 | #[test] | ||
| 90 | fn help_is_a_flag_not_an_error() { | ||
| 91 | let parsed = args(&["-h"]).unwrap(); | ||
| 92 | assert!(parsed.help); | ||
| 93 | let parsed = args(&["--help"]).unwrap(); | ||
| 94 | assert!(parsed.help); | ||
| 95 | } | ||
