summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 16:26:21 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 16:26:21 +0100
commita036e7eb46886ff7fe88255ae26c327f63ec4bfa (patch)
tree494911ce0d394041c00eff276632e4f5f48f3fb5
parentv3.2.0: straight and split bets anywhere on the felt; add history command (diff)
downloadzagan-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.toml2
-rw-r--r--docs/SERVER_SETUP.md4
-rw-r--r--docs/TESTING.md12
-rw-r--r--src/bin/client/main.rs35
-rw-r--r--src/bin/client/repl.rs12
-rw-r--r--src/client/mod.rs1
-rw-r--r--src/client/paths.rs103
-rw-r--r--tests/client_paths.rs95
8 files changed, 246 insertions, 18 deletions
diff --git a/Cargo.toml b/Cargo.toml
index 8bf04dc..66613a5 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "3.2.0" 3version = "3.3.0"
4edition = "2024" 4edition = "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
80The server creates `server.db` in the current directory on first use. It holds 80The server creates `server.db` in the current directory on first use. It holds
81users, balances, and the per-game `plays` audit. The client keeps its own 81users, 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
83migrations while the app is in alpha: a schema change means deleting both files. 85migrations while the app is in alpha: a schema change means deleting both files.
84 86
85During development, delete both files to reset users, balances, history, and 87During 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
55The server creates `server.db` in its working directory; the client creates 55The 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:
57testing, 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
60While the software is in testing, delete both after a schema or trust change:
58 61
59```sh 62```sh
60rm -f server.db client.db 63rm -f server.db
64rm -rf ~/.local/share/zagan-bet
61``` 65```
62 66
63The audit schema is not auto-migrated from the pre-2.0 slots-only `plays` 67The 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;
5mod tls; 5mod tls;
6 6
7use repl::ClientState; 7use repl::ClientState;
8use zagan_bet::client::paths::{database_path, default_data_dir, parse_args, USAGE};
8 9
9fn main() { 10fn 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
45impl ClientState { 45impl 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 @@
1pub mod core; 1pub mod core;
2pub mod database; 2pub mod database;
3pub mod games; 3pub mod games;
4pub mod paths;
4pub mod ui; 5pub 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
3use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5
6pub const APP_DIR: &str = "zagan-bet";
7pub const DATABASE_FILE: &str = "client.db";
8
9pub const USAGE: &str = "\
10usage: 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
16The 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)]
20pub 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.
27pub 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.
62pub 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
93pub 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
101pub 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 @@
1use std::ffi::OsStr;
2use std::path::PathBuf;
3
4use zagan_bet::client::paths::{data_dir_from, database_path, parse_args, APP_DIR, DATABASE_FILE};
5
6fn args(values: &[&str]) -> Result<zagan_bet::client::paths::ClientArgs, String> {
7 parse_args(values.iter().map(|value| value.to_string()))
8}
9
10#[test]
11fn 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]
45fn 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]
53fn 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]
70fn 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]
77fn 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]
90fn 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}