summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorsalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
committersalmonik <salmonik@tuta.io>2026-11-01 19:19:49 +0100
commit24e3bb54db579fb9aad8df2a591527dc77fe4cb4 (patch)
treeaf9a0c290f2675068e8396823f641f24bbe4d308
parentv4.0.6: move the prompt editor and key mapping out of the core (diff)
downloadzagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.tar.gz
zagan-bet-24e3bb54db579fb9aad8df2a591527dc77fe4cb4.zip
v4.0.7: finish the Phase 1 boundary with a command shell
The terminal command line moves to client::shell: a typed line becomes a core Command or an adapter action (connect, login, register, set-credential, host maintenance). ClientCore loses submit_line and the last TUI-shaped effects (Interactive, SetPrompt, ListHosts, ForgetHost, AnimateRoulette); the adapter animates roulette from the Result view. Adds forget-login, which drops the stored credential for the pinned key and keeps trust, distinct from logout (session only) and forget-host (trust too).
-rw-r--r--AGENTS.md3
-rw-r--r--Cargo.toml2
-rw-r--r--docs/GUI-PLAN.md38
-rw-r--r--docs/adr/0001-testable-client-server-cores.md10
-rw-r--r--src/bin/client/repl.rs190
-rw-r--r--src/client/core.rs251
-rw-r--r--src/client/database.rs15
-rw-r--r--src/client/mod.rs1
-rw-r--r--src/client/shell.rs110
-rw-r--r--src/client/ui.rs14
-rw-r--r--src/client/view.rs4
-rw-r--r--tests/client_core.rs51
-rw-r--r--tests/client_shell.rs152
-rw-r--r--tests/client_ui.rs3
-rw-r--r--tests/storage.rs21
15 files changed, 566 insertions, 299 deletions
diff --git a/AGENTS.md b/AGENTS.md
index efa8b74..3908c2d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -103,7 +103,8 @@
103 by host. 103 by host.
104- `forget_host` removes one address. Drop the credential only when no address 104- `forget_host` removes one address. Drop the credential only when no address
105 with that fingerprint remains, or two addresses for one server would stop 105 with that fingerprint remains, or two addresses for one server would stop
106 sharing a login. 106 sharing a login. `forget-login` is the narrower action: it drops the
107 credential and keeps the pinned key.
107- `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ... 108- `save_host` must not wipe the fingerprint column. Use `ON CONFLICT ...
108 DO UPDATE SET last_used` — never `INSERT OR REPLACE`. 109 DO UPDATE SET last_used` — never `INSERT OR REPLACE`.
109- The client transport lives in `client::transport`. It records the key during 110- The client transport lives in `client::transport`. It records the key during
diff --git a/Cargo.toml b/Cargo.toml
index ed5d306..9e650f2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "zagan-bet" 2name = "zagan-bet"
3version = "4.0.6" 3version = "4.0.7"
4edition = "2024" 4edition = "2024"
5license = "GPL-3.0-or-later" 5license = "GPL-3.0-or-later"
6 6
diff --git a/docs/GUI-PLAN.md b/docs/GUI-PLAN.md
index cb0db91..2789b81 100644
--- a/docs/GUI-PLAN.md
+++ b/docs/GUI-PLAN.md
@@ -24,18 +24,25 @@ Breaking wire change, released for client and server together.
24**Gate:** mismatch refused in both directions, `HELLO` required before auth, 24**Gate:** mismatch refused in both directions, `HELLO` required before auth,
25existing game flows unchanged, new tests for version parsing and gating. 25existing game flows unchanged, new tests for version parsing and gating.
26 26
27## Phase 1 — core boundary and roulette 27## Phase 1 — core boundary and roulette (done)
28 28
29**In progress.** `Command`/`View` exist and all four games are ported: the 29Complete in v4.0.7. Every game plus `Lobby` and `History` are structured views;
30core builds a typed view, frontends send `Command`, and the TUI renders it. 30the core builds the view and frontends send `Command`. The client transport
31Done so far: every game plus `Lobby` and `History` are structured views; the 31(TCP, rustls, TOFU, `HELLO`, auto-login) lives in `client::transport` behind a
32client transport (TCP, rustls, TOFU, `HELLO`, auto-login) lives in 32trust callback. The command line moved out of the core in two pieces:
33`client::transport` behind a trust callback; login and registration are 33`client::input` owns the editor, history and key mapping, and `client::shell`
34`Command`s, so a GUI form never needs a terminal prompt. The command-line 34parses a finished line into a core `Command` or an adapter action (connect,
35editor and key mapping moved to `client::input`; `ClientCore` no longer knows 35login, register, set-credential, host maintenance). `ClientCore` no longer
36keys, the prompt or its history. Remaining: the `Connect` and `Trust` adapter 36holds a `Key`, a prompt buffer, or any of the old TUI-shaped effects
37screens, and replacing the last TUI-shaped effects (`Interactive`, host 37(`Interactive`, `SetPrompt`, `ListHosts`, `ForgetHost`, `AnimateRoulette`); the
38listing, roulette animation) with commands. 38roulette wheel animation is derived from the `Result` view by the adapter.
39"Log out" (`logout`) and "forget saved login" (`forget-login`) are distinct: the
40latter drops the credential for the pinned key and keeps trust.
41
42The `Connect` and `Trust` screens are adapter-owned for now: the terminal asks
43in the transcript, and the GUI will draw them as its own screens over the same
44`client::transport` callback. They are not core state because the core never
45sees a certificate.
39 46
40- Introduce `Command` and `View`; move terminal-only concerns (prompt editing, 47- Introduce `Command` and `View`; move terminal-only concerns (prompt editing,
41 wheel animation, interactive prompts) into the TUI adapter. 48 wheel animation, interactive prompts) into the TUI adapter.
@@ -43,13 +50,14 @@ listing, roulette animation) with commands.
43 `History`, `Game`. 50 `History`, `Game`.
44- Port roulette first: `PlaceBet(BetTarget)`, `SetChip`, `ClearBets`, `Spin`, 51- Port roulette first: `PlaceBet(BetTarget)`, `SetChip`, `ClearBets`, `Spin`,
45 and a `FeltView` carrying chips, cursor, and the last result. 52 and a `FeltView` carrying chips, cursor, and the last result.
46- Add `ForgetLogin` so "log out" and "forget saved login" are distinct in both 53- Add `forget-login` so "log out" and "forget saved login" are distinct in both
47 frontends. 54 frontends.
48- Auto-login stays: a stored credential for the pinned fingerprint logs in 55- Auto-login stays: a stored credential for the pinned fingerprint logs in
49 automatically after the trust check. 56 automatically after the trust check.
50 57
51**Gate:** the TUI behaves exactly as today, its tests now assert on `View` 58**Gate:** met. The TUI behaves as before (same boards, keys and prompts), its
52instead of text where practical, and `ClientCore` contains no terminal types. 59tests drive the real adapter path (`shell::parse`, `Editor`, `game_input`), and
60`ClientCore` contains no terminal types.
53 61
54## Phase 2 — the other three games 62## Phase 2 — the other three games
55 63
diff --git a/docs/adr/0001-testable-client-server-cores.md b/docs/adr/0001-testable-client-server-cores.md
index 2e3cd1e..c19c01a 100644
--- a/docs/adr/0001-testable-client-server-cores.md
+++ b/docs/adr/0001-testable-client-server-cores.md
@@ -16,10 +16,12 @@ exercised from `tests/`.
16- The library (`src/lib.rs`) owns shared logic. `src/bin/client` and 16- The library (`src/lib.rs`) owns shared logic. `src/bin/client` and
17 `src/bin/server` are thin adapters for crossterm, TLS, environment variables, 17 `src/bin/server` are thin adapters for crossterm, TLS, environment variables,
18 and interactive prompts. 18 and interactive prompts.
19- `client::core::ClientCore` owns input, history, commands, game state, and 19- `client::core::ClientCore` owns commands, game state, the current screen, and
20 pending replies. It returns `Effect`s (`Print`, `ReplaceRegion`, `ClearRegion`, 20 pending replies. It returns `Effect`s (`Print`, `Render(View)`, `ClearRegion`,
21 `SetPrompt`, `SendLine`, `Interactive`, ...). The binary applies them; the 21 `SendLine`, `Disconnect`, `Quit`). The binary applies them; the tests apply
22 tests apply them to a headless screen. 22 them to a headless screen. The prompt buffer, history and key mapping live in
23 `client::input`, and `client::shell` parses a finished line, so the core never
24 sees a `Key` or a raw command line.
23- `server::core::ServerCore` owns message handling and receives an injected 25- `server::core::ServerCore` owns message handling and receives an injected
24 `Connection`, `GameRegistry`, and bcrypt cost. The binary only owns the TLS 26 `Connection`, `GameRegistry`, and bcrypt cost. The binary only owns the TLS
25 listener and per-connection thread. 27 listener and per-connection thread.
diff --git a/src/bin/client/repl.rs b/src/bin/client/repl.rs
index bc4238d..cfdcdd5 100644
--- a/src/bin/client/repl.rs
+++ b/src/bin/client/repl.rs
@@ -13,12 +13,14 @@ use crossterm::{
13 13
14use sha2::{Digest, Sha256}; 14use sha2::{Digest, Sha256};
15 15
16use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; 16use zagan_bet::client::core::{ClientCore, Effect};
17use zagan_bet::client::input::{game_input, GameInput, Input, Key, Editor}; 17use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
18use zagan_bet::client::shell::{self, Shell};
19use zagan_bet::client::view::{RoulettePhase, View};
18use zagan_bet::client::database; 20use zagan_bet::client::database;
19use zagan_bet::client::ui::{ 21use zagan_bet::client::ui::{
20 display_width, format_pong, host_lines, input_cursor_column, lines_to_move_up, 22 display_width, format_pong, help_lines, host_lines, input_cursor_column,
21 terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS, 23 lines_to_move_up, terminal_size_warning, visual_line_count, MIN_COLUMNS, MIN_ROWS,
22}; 24};
23use zagan_bet::protocol::io_failure_message; 25use zagan_bet::protocol::io_failure_message;
24use zagan_bet::roulette::{WHEEL, color as roulette_color}; 26use zagan_bet::roulette::{WHEEL, color as roulette_color};
@@ -42,6 +44,8 @@ pub struct ClientState {
42 terminal_size: (u16, u16), 44 terminal_size: (u16, u16),
43 /// Size we already warned about, so a resize does not spam the transcript. 45 /// Size we already warned about, so a resize does not spam the transcript.
44 size_warned: Option<(u16, u16)>, 46 size_warned: Option<(u16, u16)>,
47 /// Last roulette result the wheel animation ran for.
48 last_roulette: Option<u8>,
45} 49}
46 50
47impl ClientState { 51impl ClientState {
@@ -67,6 +71,7 @@ impl ClientState {
67 prompt_text: String::new(), 71 prompt_text: String::new(),
68 terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)), 72 terminal_size: crossterm::terminal::size().unwrap_or((MIN_COLUMNS, MIN_ROWS)),
69 size_warned: None, 73 size_warned: None,
74 last_roulette: None,
70 }) 75 })
71 } 76 }
72 77
@@ -129,7 +134,7 @@ impl ClientState {
129 } else { 134 } else {
130 match self.editor.handle(key) { 135 match self.editor.handle(key) {
131 Input::Quit => vec![Effect::Quit], 136 Input::Quit => vec![Effect::Quit],
132 Input::Submit(line) => self.core.submit_line(&line), 137 Input::Submit(line) => self.submit_line(&line),
133 Input::Print(lines) => vec![Effect::Print(lines)], 138 Input::Print(lines) => vec![Effect::Print(lines)],
134 Input::Prompt | Input::None => Vec::new(), 139 Input::Prompt | Input::None => Vec::new(),
135 } 140 }
@@ -233,17 +238,26 @@ impl ClientState {
233 } 238 }
234 } 239 }
235 Effect::Render(view) => { 240 Effect::Render(view) => {
241 if let View::Roulette(roulette) = &view {
242 if roulette.phase == RoulettePhase::Result {
243 if let Some(number) = roulette.number {
244 if self.last_roulette != Some(number) {
245 self.animate_wheel(number);
246 self.last_roulette = Some(number);
247 while crossterm::event::poll(std::time::Duration::ZERO)
248 .unwrap_or(false)
249 {
250 let _ = crossterm::event::read();
251 }
252 }
253 }
254 } else {
255 self.last_roulette = None;
256 }
257 }
236 let lines = zagan_bet::client::tui::render_view(&view); 258 let lines = zagan_bet::client::tui::render_view(&view);
237 self.draw_region(&lines); 259 self.draw_region(&lines);
238 } 260 }
239 Effect::AnimateRoulette { winning, view } => {
240 self.animate_wheel(winning);
241 let lines = zagan_bet::client::tui::render_view(&view);
242 self.draw_region(&lines);
243 while crossterm::event::poll(std::time::Duration::ZERO).unwrap_or(false) {
244 let _ = crossterm::event::read();
245 }
246 }
247 Effect::ClearRegion => self.clear_region(), 261 Effect::ClearRegion => self.clear_region(),
248 Effect::SendLine(line) => { 262 Effect::SendLine(line) => {
249 let is_ping = line == "PING"; 263 let is_ping = line == "PING";
@@ -269,37 +283,11 @@ impl ClientState {
269 return true; 283 return true;
270 } 284 }
271 } 285 }
272 Effect::Interactive(command) => {
273 self.commit_prompt_line();
274 self.run_interactive(command);
275 }
276 Effect::Disconnect => { 286 Effect::Disconnect => {
277 self.stream = None; 287 self.stream = None;
288 self.core.reset_after_disconnect();
278 self.clear_region(); 289 self.clear_region();
279 } 290 }
280 Effect::ListHosts => {
281 self.commit_prompt_line();
282 self.list_hosts();
283 }
284 Effect::ForgetHost(target) => {
285 self.commit_prompt_line();
286 match database::forget_host(&self.conn, &target) {
287 Ok(outcome) if outcome.credentials_removed => println!(
288 "Cleared trust and stored credentials for {}.",
289 target
290 ),
291 Ok(outcome) => {
292 println!("Cleared trust for {}.", target);
293 if !outcome.siblings.is_empty() {
294 println!(
295 "Stored credentials kept: this server is still reachable as {}.",
296 outcome.siblings.join(", ")
297 );
298 }
299 }
300 Err(error) => println!("Failed to forget host: {}", error),
301 }
302 }
303 Effect::Quit => { 291 Effect::Quit => {
304 let had_board = self.game_lines > 0; 292 let had_board = self.game_lines > 0;
305 self.clear_region(); 293 self.clear_region();
@@ -545,28 +533,112 @@ impl ClientState {
545 } 533 }
546 } 534 }
547 535
548 fn run_interactive(&mut self, command: InteractiveCommand) { 536 /// Run one command line from the prompt.
549 match command { 537 fn submit_line(&mut self, line: &str) -> Vec<Effect> {
550 InteractiveCommand::Login => self.do_login(), 538 match shell::parse(line) {
551 InteractiveCommand::Register => self.do_register(), 539 Shell::Run(command) => self.core.command(command),
552 InteractiveCommand::SetCred => self.do_setcred(), 540 Shell::Login => {
553 InteractiveCommand::Connect(target) => { 541 self.do_login();
554 if self.stream.is_some() { 542 Vec::new()
555 println!("Already connected. Disconnect first."); 543 }
544 Shell::Register => {
545 self.do_register();
546 Vec::new()
547 }
548 Shell::SetCred => {
549 self.do_setcred();
550 Vec::new()
551 }
552 Shell::Connect(target) => {
553 self.do_connect(target);
554 Vec::new()
555 }
556 Shell::Disconnect => {
557 self.stream = None;
558 self.core.reset_after_disconnect();
559 vec![
560 Effect::ClearRegion,
561 Effect::Print(vec!["Disconnected".to_string()]),
562 ]
563 }
564 Shell::Hosts => {
565 self.list_hosts();
566 Vec::new()
567 }
568 Shell::ForgetHost(target) => {
569 self.forget_host(target);
570 Vec::new()
571 }
572 Shell::ForgetLogin(target) => {
573 self.forget_login(target);
574 Vec::new()
575 }
576 Shell::Help => vec![Effect::Print(help_lines())],
577 Shell::Quit => vec![Effect::Quit],
578 Shell::Empty => Vec::new(),
579 Shell::Usage(message) => vec![Effect::Print(vec![message])],
580 }
581 }
582
583 fn do_connect(&mut self, target: Option<String>) {
584 if self.stream.is_some() {
585 println!("Already connected. Disconnect first.");
586 return;
587 }
588 let addr = match target {
589 Some(target) => self.normalize_addr(target),
590 None => match &self.addr {
591 Some(addr) => addr.clone(),
592 None => {
593 println!("No host specified. Usage: connect <ip>:<port>");
556 return; 594 return;
557 } 595 }
558 let addr = match target { 596 },
559 Some(target) => self.normalize_addr(target), 597 };
560 None => match &self.addr { 598 self.connect_to(&addr);
561 Some(addr) => addr.clone(), 599 }
562 None => { 600
563 println!("No host specified. Usage: connect <ip>:<port>"); 601 /// Drop the stored login for one server, keeping the pinned key.
564 return; 602 fn forget_login(&mut self, target: Option<String>) {
565 } 603 let Some(host) = target.or_else(|| self.addr.clone()) else {
566 }, 604 println!("Usage: forget-login [ip:port] (or connect first)");
567 }; 605 return;
568 self.connect_to(&addr); 606 };
607 let Some(identity) = database::get_fingerprint(&self.conn, &host) else {
608 println!("No trusted server is remembered as {}.", host);
609 return;
610 };
611 match database::forget_credentials(&self.conn, &identity) {
612 Ok(true) => println!(
613 "Forgot the saved login for server {} ({}). Trust is unchanged.",
614 short_fingerprint(&identity),
615 host
616 ),
617 Ok(false) => println!("No saved login for server {} ({}).", short_fingerprint(&identity), host),
618 Err(error) => println!("Failed to forget the saved login: {}", error),
619 }
620 }
621
622 fn forget_host(&mut self, target: Option<String>) {
623 let Some(target) = target.or_else(|| self.addr.clone()) else {
624 println!("Usage: forget-host <ip>:<port> (or connect first)");
625 return;
626 };
627 match database::forget_host(&self.conn, &target) {
628 Ok(outcome) if outcome.credentials_removed => println!(
629 "Cleared trust and stored credentials for {}.",
630 target
631 ),
632 Ok(outcome) => {
633 println!("Cleared trust for {}.", target);
634 if !outcome.siblings.is_empty() {
635 println!(
636 "Stored credentials kept: this server is still reachable as {}.",
637 outcome.siblings.join(", ")
638 );
639 }
569 } 640 }
641 Err(error) => println!("Failed to forget host: {}", error),
570 } 642 }
571 } 643 }
572 644
diff --git a/src/client/core.rs b/src/client/core.rs
index 280eb77..21c1cb2 100644
--- a/src/client/core.rs
+++ b/src/client/core.rs
@@ -6,20 +6,13 @@ use crate::client::games::response::{
6use crate::client::view::{ 6use crate::client::view::{
7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View, 7 BlackjackView, Command, CrashView, HistoryView, LobbyView, RouletteView, SlotsView, View,
8}; 8};
9use crate::client::ui::{help_lines, history_lines, parse_bet_arg}; 9use crate::client::ui::{game_lines, history_lines};
10use crate::crash::{CrashState, multiplier_at}; 10use crate::crash::{CrashState, multiplier_at};
11use crate::history::{PlayRecord, MAX_HISTORY}; 11use crate::history::{PlayRecord, MAX_HISTORY};
12use crate::roulette::{BetTarget, Bets}; 12use crate::roulette::{BetTarget, Bets};
13use std::time::{Duration, Instant}; 13use std::time::{Duration, Instant};
14 14
15 15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum InteractiveCommand {
18 Login,
19 Register,
20 SetCred,
21 Connect(Option<String>),
22}
23 16
24/// High-level actions produced by the client core. The terminal adapter is 17/// High-level actions produced by the client core. The terminal adapter is
25/// responsible for turning these into crossterm calls and network writes. 18/// responsible for turning these into crossterm calls and network writes.
@@ -29,20 +22,12 @@ pub enum Effect {
29 Print(Vec<String>), 22 Print(Vec<String>),
30 /// Draw the current view in the board region. 23 /// Draw the current view in the board region.
31 Render(View), 24 Render(View),
32 /// Run the roulette wheel animation, then draw the final view.
33 AnimateRoulette { winning: u8, view: View },
34 /// Remove the game board region. 25 /// Remove the game board region.
35 ClearRegion, 26 ClearRegion,
36 /// Send one protocol line and feed the reply to `handle_reply`. 27 /// Send one protocol line and feed the reply to `handle_reply`.
37 SendLine(String), 28 SendLine(String),
38 /// Delegate an interactive prompt flow to the terminal adapter.
39 Interactive(InteractiveCommand),
40 /// Drop the connection. 29 /// Drop the connection.
41 Disconnect, 30 Disconnect,
42 /// Let the adapter read the host database.
43 ListHosts,
44 /// Let the adapter delete a host from the database.
45 ForgetHost(String),
46 /// Leave the program. 31 /// Leave the program.
47 Quit, 32 Quit,
48} 33}
@@ -175,12 +160,8 @@ impl ClientCore {
175 Some(Pending::RouletteSpin) => match parse_roulette_response(reply) { 160 Some(Pending::RouletteSpin) => match parse_roulette_response(reply) {
176 Ok(result) => { 161 Ok(result) => {
177 self.balance = Some(result.balance); 162 self.balance = Some(result.balance);
178 let winning = result.number;
179 self.game.apply_roulette_result(result); 163 self.game.apply_roulette_result(result);
180 let Some(view) = self.view() else { 164 self.board_effects()
181 return Vec::new();
182 };
183 vec![Effect::AnimateRoulette { winning, view }]
184 } 165 }
185 Err(message) => { 166 Err(message) => {
186 self.game.set_error(message); 167 self.game.set_error(message);
@@ -331,22 +312,41 @@ impl ClientCore {
331 312
332 match command { 313 match command {
333 Command::Login { user, credential } => { 314 Command::Login { user, credential } => {
334 if !self.connected { 315 if !self.connected {
335 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 316 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
336 } 317 }
337 self.login_error = None; 318 self.login_error = None;
338 self.pending = Some(Pending::Login { user: user.clone() }); 319 self.pending = Some(Pending::Login { user: user.clone() });
339 vec![Effect::SendLine(format!("AUTH {user} {credential}"))] 320 vec![Effect::SendLine(format!("AUTH {user} {credential}"))]
340 } 321 }
341 Command::Register { user, credential } => { 322 Command::Register { user, credential } => {
342 if !self.connected { 323 if !self.connected {
343 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 324 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
344 } 325 }
345 self.login_error = None; 326 self.login_error = None;
346 self.pending = Some(Pending::Register); 327 self.pending = Some(Pending::Register);
347 vec![Effect::SendLine(format!("REGISTER {user} {credential}"))] 328 vec![Effect::SendLine(format!("REGISTER {user} {credential}"))]
348 } 329 }
349 Command::Play { game, bet } => self.play_game(&game, bet), 330 Command::Games => vec![Effect::Print(game_lines(&self.games()))],
331 Command::Ping => {
332 if !self.connected {
333 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
334 }
335 self.pending = Some(Pending::Ping);
336 vec![Effect::SendLine("PING".to_string())]
337 }
338 Command::Echo(message) => {
339 if !self.connected {
340 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
341 }
342 if message.trim().is_empty() {
343 return print_line("Usage: send <message>");
344 }
345 self.pending = Some(Pending::Echo);
346 vec![Effect::SendLine(message)]
347 }
348 Command::Info => self.info_effects(),
349 Command::Play { game, bet } => self.play_game(&game, bet),
350 Command::History { limit } => self.load_history(limit), 350 Command::History { limit } => self.load_history(limit),
351 Command::ShowLobby => { 351 Command::ShowLobby => {
352 self.screen = Screen::Lobby; 352 self.screen = Screen::Lobby;
@@ -672,106 +672,6 @@ impl ClientCore {
672 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))] 672 vec![Effect::SendLine(format!("CRASH_CASHOUT {session}"))]
673 } 673 }
674 674
675 /// Run one command line. The interactive frontend owns the buffer and the
676 /// history; the core only sees the finished line.
677 pub fn submit_line(&mut self, line: &str) -> Vec<Effect> {
678 let line = line.trim().to_string();
679 if line.is_empty() {
680 return Vec::new();
681 }
682
683 let parts: Vec<&str> = line.splitn(3, ' ').collect();
684 match parts[0] {
685 "play" => self.cmd_play(&parts),
686 "games" => self.available_games(None),
687 "help" => vec![Effect::Print(help_lines())],
688 "connect" => {
689 if self.connected {
690 print_line("Already connected. Disconnect first.")
691 } else {
692 let target = parts.get(1).map(|value| value.to_string());
693 vec![Effect::Interactive(InteractiveCommand::Connect(target))]
694 }
695 }
696 "login" => {
697 if !self.connected {
698 print_line("Not connected. Use 'connect <ip>:<port>' first.")
699 } else if self.logged_in.is_some() {
700 print_line(format!(
701 "Already logged in as {}",
702 self.logged_in.as_deref().unwrap_or("?")
703 ))
704 } else {
705 vec![Effect::Interactive(InteractiveCommand::Login)]
706 }
707 }
708 "register" => {
709 if !self.connected {
710 print_line("Not connected. Use 'connect <ip>:<port>' first.")
711 } else {
712 vec![Effect::Interactive(InteractiveCommand::Register)]
713 }
714 }
715 "setcred" => {
716 if self.host.is_none() {
717 print_line("No host connected. Use 'connect <ip>:<port>' first.")
718 } else {
719 vec![Effect::Interactive(InteractiveCommand::SetCred)]
720 }
721 }
722 "logout" => {
723 self.logged_in = None;
724 print_line("Logged out")
725 }
726 "disconnect" => {
727 self.connected = false;
728 self.logged_in = None;
729 self.pending = None;
730 self.crash_started = None;
731 self.game.end_game();
732 vec![
733 Effect::ClearRegion,
734 Effect::Disconnect,
735 Effect::Print(vec!["Disconnected".to_string()]),
736 ]
737 }
738 "ping" => {
739 if !self.connected {
740 print_line("Not connected. Use 'connect <ip>:<port>' first.")
741 } else {
742 self.pending = Some(Pending::Ping);
743 vec![Effect::SendLine("PING".to_string())]
744 }
745 }
746 "send" => {
747 if !self.connected {
748 print_line("Not connected. Use 'connect <ip>:<port>' first.")
749 } else if parts.len() < 2 {
750 print_line("Usage: send <message>")
751 } else {
752 let message = parts[1..].join(" ");
753 self.pending = Some(Pending::Echo);
754 vec![Effect::SendLine(message)]
755 }
756 }
757 "info" => self.cmd_info(),
758 "history" => self.cmd_history(&parts),
759 "hosts" => vec![Effect::ListHosts],
760 "forget-host" => {
761 let target = parts
762 .get(1)
763 .map(|value| value.to_string())
764 .or_else(|| self.host.clone());
765 match target {
766 Some(target) => vec![Effect::ForgetHost(target)],
767 None => print_line("Usage: forget-host <ip>:<port> (or connect first)"),
768 }
769 }
770 "quit" => vec![Effect::Quit],
771 _ => vec![Effect::Print(help_lines())],
772 }
773 }
774
775 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> { 675 fn play_game(&mut self, game_type: &str, bet: Option<u64>) -> Vec<Effect> {
776 if !self.connected { 676 if !self.connected {
777 return print_line("Not connected. Use 'connect <ip>:<port>' first."); 677 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
@@ -822,56 +722,7 @@ impl ClientCore {
822 vec![Effect::SendLine(request)] 722 vec![Effect::SendLine(request)]
823 } 723 }
824 724
825 fn cmd_play(&mut self, parts: &[&str]) -> Vec<Effect> { 725 fn info_effects(&mut self) -> Vec<Effect> {
826 if !self.connected {
827 return print_line("Not connected. Use 'connect <ip>:<port>' first.");
828 }
829 if self.logged_in.is_none() {
830 return print_line("Not logged in. Use 'login' first.");
831 }
832 if parts.len() < 2 {
833 return self.available_games(Some("Usage: play <game> [bet]"));
834 }
835
836 let bet = match parts.get(2) {
837 Some(raw) => match parse_bet_arg(raw) {
838 Ok(bet) => Some(bet),
839 Err(message) => return print_line(message),
840 },
841 None => None,
842 };
843
844 self.command(Command::Play {
845 game: parts[1].to_string(),
846 bet,
847 })
848 }
849
850 fn available_games(&self, usage: Option<&str>) -> Vec<Effect> {
851 let mut lines = vec!["Available games:".to_string()];
852 for (game_type, name) in self.game.get_available_games() {
853 lines.push(format!(" - {} ({})", game_type, name));
854 }
855 if let Some(usage) = usage {
856 lines.push(String::new());
857 lines.push(usage.to_string());
858 }
859 vec![Effect::Print(lines)]
860 }
861
862 fn cmd_history(&mut self, parts: &[&str]) -> Vec<Effect> {
863 let limit = match parts.get(1) {
864 Some(raw) => match raw.parse::<u32>() {
865 Ok(count) => count,
866 Err(_) => return print_line(format!("Usage: history [1-{MAX_HISTORY}]")),
867 },
868 None => crate::history::DEFAULT_HISTORY,
869 };
870
871 self.command(Command::History { limit })
872 }
873
874 fn cmd_info(&mut self) -> Vec<Effect> {
875 if !self.connected { 726 if !self.connected {
876 return print_line("Not connected to any host."); 727 return print_line("Not connected to any host.");
877 } 728 }
@@ -895,7 +746,24 @@ impl ClientCore {
895 effects 746 effects
896 } 747 }
897 748
898 /// Snapshot of the current screen for a frontend to draw. 749 /// The games a frontend can offer.
750 pub fn games(&self) -> Vec<(String, String)> {
751 self.game
752 .get_available_games()
753 .into_iter()
754 .map(|(game, name)| (game.to_string(), name.to_string()))
755 .collect()
756 }
757
758 /// Reset connection-dependent state after the adapter closed the socket.
759 ///
760 /// The transport belongs to the frontend, so it tells the core when the
761 /// link is gone. What to say about it is the frontend's business.
762 pub fn reset_after_disconnect(&mut self) {
763 self.set_connected(false);
764 self.game.end_game();
765 }
766
899 pub fn view(&self) -> Option<View> { 767 pub fn view(&self) -> Option<View> {
900 if let Some(state) = self.game.get_current_state() { 768 if let Some(state) = self.game.get_current_state() {
901 return Some(match state.game_type.as_str() { 769 return Some(match state.game_type.as_str() {
@@ -915,12 +783,7 @@ impl ClientCore {
915 connected: self.connected, 783 connected: self.connected,
916 user: self.logged_in.clone(), 784 user: self.logged_in.clone(),
917 balance: self.balance, 785 balance: self.balance,
918 games: self 786 games: self.games(),
919 .game
920 .get_available_games()
921 .into_iter()
922 .map(|(game, name)| (game.to_string(), name.to_string()))
923 .collect(),
924 login_error: self.login_error.clone(), 787 login_error: self.login_error.clone(),
925 }), 788 }),
926 }) 789 })
diff --git a/src/client/database.rs b/src/client/database.rs
index c4601b5..40ee8b4 100644
--- a/src/client/database.rs
+++ b/src/client/database.rs
@@ -151,6 +151,21 @@ pub fn forget_host(conn: &Connection, host: &str) -> Result<ForgetOutcome, rusql
151 }) 151 })
152} 152}
153 153
154/// Forget the saved login for a server identity ("forget saved login").
155///
156/// Trust is untouched: the pinned key stays in `hosts`, so the next connection
157/// only asks for a password. Returns whether a credential was actually stored.
158pub fn forget_credentials(
159 conn: &Connection,
160 fingerprint: &str,
161) -> Result<bool, rusqlite::Error> {
162 let removed = conn.execute(
163 "DELETE FROM credentials WHERE fingerprint = ?1",
164 [fingerprint],
165 )?;
166 Ok(removed > 0)
167}
168
154/// List all remembered hosts with their fingerprints. 169/// List all remembered hosts with their fingerprints.
155pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> { 170pub fn get_all_hosts(conn: &Connection) -> Vec<(String, Option<String>)> {
156 let mut stmt = conn 171 let mut stmt = conn
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 1d79d97..e3bc1b3 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -3,6 +3,7 @@ pub mod database;
3pub mod games; 3pub mod games;
4pub mod input; 4pub mod input;
5pub mod paths; 5pub mod paths;
6pub mod shell;
6pub mod transport; 7pub mod transport;
7pub mod tui; 8pub mod tui;
8pub mod ui; 9pub mod ui;
diff --git a/src/client/shell.rs b/src/client/shell.rs
new file mode 100644
index 0000000..73843c8
--- /dev/null
+++ b/src/client/shell.rs
@@ -0,0 +1,110 @@
1//! Parser for the terminal's command line.
2//!
3//! The core speaks `Command`; this module turns one typed line into either a
4//! core command or an action only an interactive frontend can perform
5//! (connecting, prompting for a password, listing hosts).
6
7use crate::client::ui::parse_bet_arg;
8use crate::client::view::Command;
9use crate::history::{DEFAULT_HISTORY, MAX_HISTORY};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum Shell {
13 /// A command the core can run on its own.
14 Run(Command),
15 Login,
16 Register,
17 SetCred,
18 Connect(Option<String>),
19 Disconnect,
20 Hosts,
21 ForgetHost(Option<String>),
22 ForgetLogin(Option<String>),
23 Help,
24 Quit,
25 Empty,
26 Usage(String),
27}
28
29/// Split off the first whitespace-delimited word.
30///
31/// The remainder keeps its internal spacing (so `send` can echo a message
32/// verbatim) but loses the separator run before it.
33fn split_word(line: &str) -> (&str, &str) {
34 match line.find(char::is_whitespace) {
35 Some(index) => (&line[..index], line[index..].trim_start()),
36 None => (line, ""),
37 }
38}
39
40pub fn parse(line: &str) -> Shell {
41 let line = line.trim();
42 if line.is_empty() {
43 return Shell::Empty;
44 }
45
46 let (word, rest) = split_word(line);
47 match word {
48 "play" => {
49 let (game, bet) = split_word(rest);
50 if game.is_empty() {
51 return Shell::Usage("Usage: play <game> [bet]".to_string());
52 }
53 let bet = if bet.is_empty() {
54 None
55 } else {
56 match parse_bet_arg(bet) {
57 Ok(bet) => Some(bet),
58 Err(message) => return Shell::Usage(message),
59 }
60 };
61 Shell::Run(Command::Play {
62 game: game.to_string(),
63 bet,
64 })
65 }
66 "history" => {
67 let (raw, _) = split_word(rest);
68 let limit = if raw.is_empty() {
69 DEFAULT_HISTORY
70 } else {
71 match raw.parse::<u32>() {
72 Ok(count) if (1..=MAX_HISTORY).contains(&count) => count,
73 _ => return Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]")),
74 }
75 };
76 Shell::Run(Command::History { limit })
77 }
78 "games" => Shell::Run(Command::Games),
79 "logout" => Shell::Run(Command::Logout),
80 "ping" => Shell::Run(Command::Ping),
81 "send" => {
82 if rest.is_empty() {
83 Shell::Usage("Usage: send <message>".to_string())
84 } else {
85 Shell::Run(Command::Echo(rest.to_string()))
86 }
87 }
88 "info" => Shell::Run(Command::Info),
89 "help" => Shell::Help,
90 "connect" => Shell::Connect(optional_word(rest)),
91 "login" => Shell::Login,
92 "register" => Shell::Register,
93 "setcred" => Shell::SetCred,
94 "disconnect" => Shell::Disconnect,
95 "hosts" => Shell::Hosts,
96 "forget-host" => Shell::ForgetHost(optional_word(rest)),
97 "forget-login" => Shell::ForgetLogin(optional_word(rest)),
98 "quit" => Shell::Quit,
99 _ => Shell::Help,
100 }
101}
102
103fn optional_word(rest: &str) -> Option<String> {
104 let (word, _) = split_word(rest);
105 if word.is_empty() {
106 None
107 } else {
108 Some(word.to_string())
109 }
110}
diff --git a/src/client/ui.rs b/src/client/ui.rs
index bb5b55f..7439e0f 100644
--- a/src/client/ui.rs
+++ b/src/client/ui.rs
@@ -76,6 +76,11 @@ const COMMANDS: &[CommandHelp] = &[
76 description: "clear trust for one address (credentials stay while the key is in use)", 76 description: "clear trust for one address (credentials stay while the key is in use)",
77 }, 77 },
78 CommandHelp { 78 CommandHelp {
79 name: "forget-login",
80 usage: "forget-login [ip:port]",
81 description: "forget the saved login (trust is kept)",
82 },
83 CommandHelp {
79 name: "hosts", 84 name: "hosts",
80 usage: "hosts", 85 usage: "hosts",
81 description: "list remembered hosts", 86 description: "list remembered hosts",
@@ -272,6 +277,15 @@ pub fn terminal_size_warning(columns: u16, rows: u16) -> Option<String> {
272 )) 277 ))
273} 278}
274 279
280/// List of games for the terminal's `games` command.
281pub fn game_lines(games: &[(String, String)]) -> Vec<String> {
282 let mut lines = vec!["Available games:".to_string()];
283 for (game, name) in games {
284 lines.push(format!(" - {game} ({name})"));
285 }
286 lines
287}
288
275/// One line per settled play, newest first. 289/// One line per settled play, newest first.
276pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> { 290pub fn history_lines(records: &[crate::history::PlayRecord]) -> Vec<String> {
277 if records.is_empty() { 291 if records.is_empty() {
diff --git a/src/client/view.rs b/src/client/view.rs
index 9443726..d2ccadb 100644
--- a/src/client/view.rs
+++ b/src/client/view.rs
@@ -274,6 +274,10 @@ pub enum View {
274pub enum Command { 274pub enum Command {
275 /// The credential is already hashed by the frontend. 275 /// The credential is already hashed by the frontend.
276 Login { user: String, credential: String }, 276 Login { user: String, credential: String },
277 Games,
278 Ping,
279 Echo(String),
280 Info,
277 Register { user: String, credential: String }, 281 Register { user: String, credential: String },
278 Play { game: String, bet: Option<u64> }, 282 Play { game: String, bet: Option<u64> },
279 History { limit: u32 }, 283 History { limit: u32 },
diff --git a/tests/client_core.rs b/tests/client_core.rs
index 722cf0d..49abca2 100644
--- a/tests/client_core.rs
+++ b/tests/client_core.rs
@@ -6,8 +6,10 @@
6 6
7use std::time::Duration; 7use std::time::Duration;
8use zagan_bet::blackjack::BlackjackState; 8use zagan_bet::blackjack::BlackjackState;
9use zagan_bet::client::core::{ClientCore, Effect, InteractiveCommand}; 9use zagan_bet::client::core::{ClientCore, Effect};
10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key}; 10use zagan_bet::client::input::{game_input, Editor, GameInput, Input, Key};
11use zagan_bet::client::shell::{self, Shell};
12use zagan_bet::client::ui::help_lines;
11use zagan_bet::client::games::response::{ 13use zagan_bet::client::games::response::{
12 parse_blackjack_response, parse_crash_response, parse_spin_response, 14 parse_blackjack_response, parse_crash_response, parse_spin_response,
13}; 15};
@@ -31,12 +33,7 @@ struct Screen {
31} 33}
32 34
33impl Screen { 35impl Screen {
34 fn apply( 36 fn apply(&mut self, effects: &[Effect], sent: &mut Vec<String>) {
35 &mut self,
36 effects: &[Effect],
37 sent: &mut Vec<String>,
38 interactive: &mut Vec<InteractiveCommand>,
39 ) {
40 for effect in effects { 37 for effect in effects {
41 match effect { 38 match effect {
42 Effect::Print(lines) => self.lines.extend(lines.iter().cloned()), 39 Effect::Print(lines) => self.lines.extend(lines.iter().cloned()),
@@ -57,19 +54,8 @@ impl Screen {
57 self.region_start = None; 54 self.region_start = None;
58 self.region_len = 0; 55 self.region_len = 0;
59 } 56 }
60 Effect::AnimateRoulette { view, .. } => {
61 let lines = zagan_bet::client::tui::render_view(view);
62 if let Some(start) = self.region_start {
63 self.lines.truncate(start);
64 } else {
65 self.region_start = Some(self.lines.len());
66 }
67 self.lines.extend(lines.iter().cloned());
68 self.region_len = lines.len();
69 }
70 Effect::SendLine(line) => sent.push(line.clone()), 57 Effect::SendLine(line) => sent.push(line.clone()),
71 Effect::Interactive(command) => interactive.push(command.clone()), 58 Effect::Quit | Effect::Disconnect => {}
72 Effect::Quit | Effect::Disconnect | Effect::ListHosts | Effect::ForgetHost(_) => {}
73 } 59 }
74 } 60 }
75 } 61 }
@@ -87,7 +73,6 @@ struct Harness {
87 editor: Editor, 73 editor: Editor,
88 screen: Screen, 74 screen: Screen,
89 sent: Vec<String>, 75 sent: Vec<String>,
90 interactive: Vec<InteractiveCommand>,
91} 76}
92 77
93impl Harness { 78impl Harness {
@@ -100,13 +85,11 @@ impl Harness {
100 editor: Editor::new(), 85 editor: Editor::new(),
101 screen: Screen::default(), 86 screen: Screen::default(),
102 sent: Vec::new(), 87 sent: Vec::new(),
103 interactive: Vec::new(),
104 } 88 }
105 } 89 }
106 90
107 fn apply(&mut self, effects: Vec<Effect>) { 91 fn apply(&mut self, effects: Vec<Effect>) {
108 self.screen 92 self.screen.apply(&effects, &mut self.sent);
109 .apply(&effects, &mut self.sent, &mut self.interactive);
110 } 93 }
111 94
112 fn key(&mut self, key: Key) { 95 fn key(&mut self, key: Key) {
@@ -119,7 +102,7 @@ impl Harness {
119 } else { 102 } else {
120 match self.editor.handle(key) { 103 match self.editor.handle(key) {
121 Input::Quit => vec![Effect::Quit], 104 Input::Quit => vec![Effect::Quit],
122 Input::Submit(line) => self.core.submit_line(&line), 105 Input::Submit(line) => self.dispatch(&line),
123 Input::Print(lines) => vec![Effect::Print(lines)], 106 Input::Print(lines) => vec![Effect::Print(lines)],
124 Input::Prompt | Input::None => Vec::new(), 107 Input::Prompt | Input::None => Vec::new(),
125 } 108 }
@@ -128,6 +111,26 @@ impl Harness {
128 self.apply_prompt(); 111 self.apply_prompt();
129 } 112 }
130 113
114 /// Route a submitted line the way the terminal adapter does.
115 fn dispatch(&mut self, line: &str) -> Vec<Effect> {
116 match shell::parse(line) {
117 Shell::Run(command) => self.core.command(command),
118 Shell::Disconnect => {
119 self.core.reset_after_disconnect();
120 vec![
121 Effect::ClearRegion,
122 Effect::Print(vec!["Disconnected".to_string()]),
123 ]
124 }
125 Shell::Help => vec![Effect::Print(help_lines())],
126 Shell::Quit => vec![Effect::Quit],
127 Shell::Empty => Vec::new(),
128 Shell::Usage(message) => vec![Effect::Print(vec![message])],
129 // Connect/login prompts are adapter flows, not core state.
130 _ => Vec::new(),
131 }
132 }
133
131 fn apply_prompt(&mut self) { 134 fn apply_prompt(&mut self) {
132 if self.core.is_in_game() { 135 if self.core.is_in_game() {
133 return; 136 return;
diff --git a/tests/client_shell.rs b/tests/client_shell.rs
new file mode 100644
index 0000000..d286b46
--- /dev/null
+++ b/tests/client_shell.rs
@@ -0,0 +1,152 @@
1//! The terminal command line is a thin parser over `Command`.
2//!
3//! Anything the core can do on its own becomes `Shell::Run`; connecting,
4//! prompting for credentials and host maintenance stay with the adapter.
5
6use zagan_bet::client::shell::{Shell, parse};
7use zagan_bet::client::view::Command;
8use zagan_bet::history::{DEFAULT_HISTORY, MAX_HISTORY};
9
10fn run(line: &str) -> Command {
11 match parse(line) {
12 Shell::Run(command) => command,
13 other => panic!("expected a command, got {other:?}"),
14 }
15}
16
17#[test]
18fn blank_lines_do_nothing() {
19 assert_eq!(parse(""), Shell::Empty);
20 assert_eq!(parse(" \t "), Shell::Empty);
21}
22
23#[test]
24fn play_without_a_game_prints_usage() {
25 assert_eq!(
26 parse("play"),
27 Shell::Usage("Usage: play <game> [bet]".to_string())
28 );
29}
30
31#[test]
32fn play_carries_the_game_and_an_optional_bet() {
33 assert_eq!(
34 run("play slots"),
35 Command::Play {
36 game: "slots".to_string(),
37 bet: None,
38 }
39 );
40 assert_eq!(
41 run("play roulette 25"),
42 Command::Play {
43 game: "roulette".to_string(),
44 bet: Some(25),
45 }
46 );
47}
48
49#[test]
50fn play_rejects_a_bet_below_the_table_minimum() {
51 assert_eq!(
52 parse("play slots 5"),
53 Shell::Usage("Bet must be at least 10 Kr".to_string())
54 );
55 assert_eq!(
56 parse("play slots ten"),
57 Shell::Usage("Invalid bet: ten".to_string())
58 );
59}
60
61#[test]
62fn history_defaults_to_the_recent_limit() {
63 let expected = Command::History {
64 limit: DEFAULT_HISTORY,
65 };
66 assert_eq!(run("history"), expected);
67 assert_eq!(run("history 5"), Command::History { limit: 5 });
68}
69
70#[test]
71fn history_rejects_counts_outside_the_range() {
72 let usage = Shell::Usage(format!("Usage: history [1-{MAX_HISTORY}]"));
73 assert_eq!(parse("history 0"), usage);
74 assert_eq!(parse(&format!("history {}", MAX_HISTORY + 1)), usage);
75 assert_eq!(parse("history many"), usage);
76}
77
78#[test]
79fn simple_commands_are_pure_core_commands() {
80 assert_eq!(run("games"), Command::Games);
81 assert_eq!(run("logout"), Command::Logout);
82 assert_eq!(run("ping"), Command::Ping);
83 assert_eq!(run("info"), Command::Info);
84}
85
86#[test]
87fn send_keeps_the_whole_message_including_extra_spaces() {
88 assert_eq!(
89 run("send hello there friend"),
90 Command::Echo("hello there friend".to_string())
91 );
92 assert_eq!(
93 parse("send"),
94 Shell::Usage("Usage: send <message>".to_string())
95 );
96}
97
98#[test]
99fn connection_flows_stay_with_the_adapter() {
100 assert_eq!(parse("connect"), Shell::Connect(None));
101 assert_eq!(
102 parse("connect 127.0.0.1:11525"),
103 Shell::Connect(Some("127.0.0.1:11525".to_string()))
104 );
105 assert_eq!(parse("login"), Shell::Login);
106 assert_eq!(parse("register"), Shell::Register);
107 assert_eq!(parse("setcred"), Shell::SetCred);
108 assert_eq!(parse("disconnect"), Shell::Disconnect);
109}
110
111#[test]
112fn host_maintenance_is_adapter_owned() {
113 assert_eq!(parse("hosts"), Shell::Hosts);
114 assert_eq!(parse("forget-host"), Shell::ForgetHost(None));
115 assert_eq!(
116 parse("forget-host 10.0.0.2:11525"),
117 Shell::ForgetHost(Some("10.0.0.2:11525".to_string()))
118 );
119}
120
121#[test]
122fn forgetting_a_saved_login_is_separate_from_forgetting_trust() {
123 assert_eq!(parse("forget-login"), Shell::ForgetLogin(None));
124 assert_eq!(
125 parse("forget-login 10.0.0.2:11525"),
126 Shell::ForgetLogin(Some("10.0.0.2:11525".to_string()))
127 );
128}
129
130#[test]
131fn help_and_quit_are_terminal_only() {
132 assert_eq!(parse("help"), Shell::Help);
133 assert_eq!(parse("quit"), Shell::Quit);
134}
135
136#[test]
137fn an_unknown_word_falls_back_to_help() {
138 assert_eq!(parse("frobnicate"), Shell::Help);
139 assert_eq!(parse("quit now"), Shell::Quit);
140}
141
142#[test]
143fn surrounding_whitespace_is_ignored() {
144 assert_eq!(run(" ping "), Command::Ping);
145 assert_eq!(
146 run(" play slots 30 "),
147 Command::Play {
148 game: "slots".to_string(),
149 bet: Some(30),
150 }
151 );
152}
diff --git a/tests/client_ui.rs b/tests/client_ui.rs
index 6e00903..bd9ccbc 100644
--- a/tests/client_ui.rs
+++ b/tests/client_ui.rs
@@ -27,7 +27,8 @@ fn help_documents_every_command() {
27fn completion_knows_every_command() { 27fn completion_knows_every_command() {
28 let commands = command_names(); 28 let commands = command_names();
29 for expected in [ 29 for expected in [
30 "connect", "games", "play", "hosts", "send", "logout", "quit", 30 "connect", "games", "play", "hosts", "forget-host", "forget-login", "send", "logout",
31 "quit",
31 ] { 32 ] {
32 assert!( 33 assert!(
33 commands.contains(&expected), 34 commands.contains(&expected),
diff --git a/tests/storage.rs b/tests/storage.rs
index e6ad2d1..21e76d4 100644
--- a/tests/storage.rs
+++ b/tests/storage.rs
@@ -89,6 +89,27 @@ fn forgetting_one_address_keeps_the_shared_credentials() {
89} 89}
90 90
91#[test] 91#[test]
92fn forgetting_the_login_keeps_the_trusted_key() {
93 let (_, conn) = tmp_db("creds-forget-login");
94 client_database::init_db(&conn);
95
96 client_database::save_host(&conn, "127.0.0.1:11525");
97 client_database::save_fingerprint(&conn, "127.0.0.1:11525", "server-a").unwrap();
98 client_database::save_credentials(&conn, "server-a", "alice", "pw").unwrap();
99
100 assert!(client_database::forget_credentials(&conn, "server-a").unwrap());
101 assert_eq!(client_database::get_credentials(&conn, "server-a"), None);
102 assert_eq!(
103 client_database::get_fingerprint(&conn, "127.0.0.1:11525"),
104 Some("server-a".to_string()),
105 "forgetting the login must not unpin the key"
106 );
107
108 // A second call is a no-op, not an error.
109 assert!(!client_database::forget_credentials(&conn, "server-a").unwrap());
110}
111
112#[test]
92fn client_last_host_tracking() { 113fn client_last_host_tracking() {
93 let (_, conn) = tmp_db("hosts"); 114 let (_, conn) = tmp_db("hosts");
94 client_database::init_db(&conn); 115 client_database::init_db(&conn);