Skip to content

Commit baa033f

Browse files
committed
fix(qauld-ctl): line editing, quoted args, account crash & help in shell
Fixes a batch of `qauld-ctl shell` issues: - Line editing via rustyline: arrow up/down recall command history (also persisted to ~/.qauld-ctl_history), arrow left/right move the cursor to edit the current line. Replaces the canonical-mode line reader; async events now print through rustyline's ExternalPrinter so they don't corrupt the line being edited. - Quoted arguments with spaces work now, e.g. `feed send -m "hello my friend"`: the line is tokenised with shell-style quoting (shlex) instead of split_whitespace. - `account login/logout/status` no longer crash. They hit a `todo!()` in encode_request that panicked the whole process (taking the shell down with it); they now delegate to the auth flow — login runs the challenge-response, logout/status return a graceful error — matching the `auth` command. - `help` prints the real command list (rendered clap help) instead of a vague pointer, and the same help screen is shown on shell startup. Verified end-to-end through a pty: history recall, cursor-edit, a quoted message round-tripped through a live daemon, and the account subcommands (graceful, no panic). Adds a regression test for the account crash.
1 parent fbc91d3 commit baa033f

3 files changed

Lines changed: 161 additions & 135 deletions

File tree

‎rust/clients/qauld-ctl/Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ serde_json = "1.0.140"
4141
async-trait = "0.1"
4242
ratatui = { version = "0.29", optional = true }
4343
crossterm = { version = "0.28", features = ["event-stream"], optional = true }
44+
# Interactive `shell` line editing: history (↑/↓), cursor movement (←/→),
45+
# and an external printer so live events don't corrupt the edited line.
46+
rustyline = "14"
47+
# Shell-style argument tokenisation so quoted values with spaces work,
48+
# e.g. `chat send -m "hello my friend"`.
49+
shlex = "1"
4450

4551
[target.'cfg(unix)'.dependencies]
4652
libc = "0.2"

‎rust/clients/qauld-ctl/src/commands/user_accounts.rs‎

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
use prost::Message;
22

3-
use crate::{cli::AccountSubcmd, commands::RpcCommand, proto::Modules};
3+
use crate::{
4+
cli::{AccountSubcmd, AuthSubcmd},
5+
commands::RpcCommand,
6+
proto::Modules,
7+
};
48

59
/// protobuf RPC definition
610
use qaul_proto::qaul_rpc_user_accounts as proto;
@@ -126,13 +130,31 @@ impl RpcCommand for AccountSubcmd {
126130
proto_message.encode(&mut buf)?;
127131
Ok((buf, Modules::Useraccounts))
128132
}
129-
_ => {
130-
todo!()
133+
// Session subcommands (login / logout / status) share the legacy
134+
// auth challenge-response flow; delegate to the auth command so
135+
// they behave identically — and, crucially, never panic. (This
136+
// arm previously `todo!()`-ed and crashed the whole process,
137+
// including `qauld-ctl shell`.)
138+
AccountSubcmd::Login { username, password } => AuthSubcmd::Login {
139+
username: username.clone(),
140+
password: password.clone(),
131141
}
142+
.encode_request(),
143+
AccountSubcmd::Logout => AuthSubcmd::Logout.encode_request(),
144+
AccountSubcmd::Status => AuthSubcmd::Status.encode_request(),
132145
}
133146
}
134147

135148
fn decode_response(&self, data: &[u8], json: bool) -> Result<(), Box<dyn std::error::Error>> {
149+
// `account login` rides the auth proto (challenge-response), not the
150+
// user-accounts proto — decode it through the auth implementation.
151+
if let AccountSubcmd::Login { username, password } = self {
152+
return AuthSubcmd::Login {
153+
username: username.clone(),
154+
password: password.clone(),
155+
}
156+
.decode_response(data, json);
157+
}
136158
let user_accounts = UserAccounts::decode(data)?;
137159
match user_accounts.message {
138160
Some(user_accounts::Message::DefaultUserAccount(default_useraccount)) => {
@@ -230,3 +252,29 @@ impl RpcCommand for AccountSubcmd {
230252
Ok(())
231253
}
232254
}
255+
256+
#[cfg(test)]
257+
mod tests {
258+
use super::*;
259+
260+
/// Regression: `account login/logout/status` used to hit a `todo!()` in
261+
/// `encode_request`, panicking and crashing the whole process (including
262+
/// `qauld-ctl shell`). They now delegate to the auth flow and never panic.
263+
#[test]
264+
fn session_subcommands_do_not_panic() {
265+
// Logout/Status have no wire message; they return a graceful Err.
266+
assert!(AccountSubcmd::Logout.encode_request().is_err());
267+
assert!(AccountSubcmd::Status.encode_request().is_err());
268+
269+
// Login encodes an AuthRequest routed to the Auth module.
270+
let res = AccountSubcmd::Login {
271+
username: "abc".to_string(), // valid base58
272+
password: "pw".to_string(),
273+
}
274+
.encode_request();
275+
assert!(res.is_ok(), "login should encode without panicking");
276+
if let Ok((_, module)) = res {
277+
assert_eq!(module, Modules::Auth);
278+
}
279+
}
280+
}

‎rust/clients/qauld-ctl/src/shell/mod.rs‎

Lines changed: 104 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -3,33 +3,25 @@
33

44
//! Interactive shell mode for qauld-ctl.
55
//!
6-
//! Reads command lines from stdin, parses each line through the same `clap`
7-
//! grammar used by single-shot mode, and dispatches them via the existing
8-
//! `run` function. A fresh socket is opened per command — the same code path
9-
//! that single-shot uses, so behaviour is identical aside from the prompt
10-
//! and the persistent process.
6+
//! Reads command lines through `rustyline` (so arrow up/down recall history
7+
//! and arrow left/right move the cursor for editing), tokenises them with
8+
//! shell-style quoting (`shlex`, so `chat send -m "hello my friend"` works),
9+
//! parses each line through the same `clap` grammar used by single-shot mode,
10+
//! and dispatches it via the existing `run` function. A fresh socket is opened
11+
//! per command — the same code path single-shot uses, so behaviour is
12+
//! identical aside from the prompt and the persistent process.
1113
//!
12-
//! The shell also opens a long-running event subscription on a separate
13-
//! socket and prints incoming events in-line. When an event arrives while
14-
//! the user is typing, we print the event on its own line and re-print the
15-
//! prompt; characters typed before the event remain in the kernel's line
16-
//! buffer (canonical mode) and will be delivered on Enter, but they will
17-
//! visually appear on the new prompt line.
18-
//!
19-
//! Limitations of this initial implementation:
20-
//! - No line editing or history (we use canonical-mode line input via
21-
//! `tokio::io::stdin`); add `rustyline` or similar later if the
22-
//! experience demands it.
23-
//! - Argument tokenisation uses `split_whitespace`, so quoted arguments
24-
//! (`feed send -m "hello world"`) won't parse correctly. Use single-word
25-
//! arguments for now, or add a shell-style splitter (e.g. `shlex`) later.
14+
//! A long-running event subscription runs in the background and prints
15+
//! incoming events via rustyline's `ExternalPrinter`, which redraws the line
16+
//! being edited so async events never corrupt the user's input.
2617
27-
use clap::Parser;
28-
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
18+
use clap::{CommandFactory, Parser};
19+
use rustyline::error::ReadlineError;
20+
use rustyline::{DefaultEditor, ExternalPrinter};
2921

3022
use crate::cli::{Cli, Commands};
3123

32-
const PROMPT: &[u8] = b"qauld> ";
24+
const PROMPT: &str = "qauld> ";
3325

3426
/// Run the interactive shell loop until the user types `quit` / `exit`
3527
/// or sends EOF (Ctrl-D).
@@ -39,71 +31,70 @@ const PROMPT: &[u8] = b"qauld> ";
3931
/// / `--dir` are ignored so the connection target is predictable; per-line
4032
/// `--json` is honoured per command.
4133
pub async fn run(shell_cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
42-
print_banner();
43-
44-
// Open a background event subscription. If the daemon isn't running
45-
// yet we surface a warning but keep the shell usable — the user can
46-
// still type commands; each command will fail with the same
47-
// "connection failed" message until the daemon is up.
48-
let mut event_rx = match crate::subscribe::spawn_event_listener(&shell_cli).await {
49-
Ok(rx) => Some(rx),
34+
// Line editor: history (↑/↓), cursor movement + editing (←/↑ etc.).
35+
let mut rl = DefaultEditor::new()?;
36+
let history = history_path();
37+
if let Some(path) = history.as_ref() {
38+
// A missing history file on first run is fine.
39+
let _ = rl.load_history(path);
40+
}
41+
42+
// Background event subscription. Events print via rustyline's external
43+
// printer so they don't corrupt the line being edited. If the daemon
44+
// isn't up yet we warn but keep the shell usable.
45+
match crate::subscribe::spawn_event_listener(&shell_cli).await {
46+
Ok(mut event_rx) => match rl.create_external_printer() {
47+
Ok(mut printer) => {
48+
tokio::spawn(async move {
49+
while let Some(line) = event_rx.recv().await {
50+
if printer.print(format!("{line}\n")).is_err() {
51+
break;
52+
}
53+
}
54+
});
55+
}
56+
Err(e) => {
57+
eprintln!("shell: live events unavailable ({e}); commands still work");
58+
}
59+
},
5060
Err(e) => {
51-
eprintln!(
52-
"shell: event subscription unavailable ({e}); commands still work"
53-
);
54-
None
61+
eprintln!("shell: event subscription unavailable ({e}); commands still work");
5562
}
56-
};
63+
}
5764

58-
let mut stdout = tokio::io::stdout();
59-
let stdin = tokio::io::stdin();
60-
let mut reader = BufReader::new(stdin).lines();
65+
// Show the help screen on startup.
66+
print_intro();
67+
print_help();
6168

6269
loop {
63-
if let Err(e) = write_prompt(&mut stdout).await {
64-
log::error!("shell: failed to write prompt: {e}");
65-
break;
66-
}
67-
68-
// Race the user's next line against any pushed events. If an
69-
// event wins, we print it and loop back to redraw the prompt.
70-
let line = loop {
71-
tokio::select! {
72-
biased;
73-
ev = recv_event(&mut event_rx) => {
74-
if let Some(line) = ev {
75-
// Drop to a fresh line, print the event, redraw prompt.
76-
// Any chars the user typed-but-not-Entered are still
77-
// in the kernel's line buffer and will be delivered
78-
// on Enter — they just visually move to the new line.
79-
print_async_event(&mut stdout, &line).await;
80-
if let Err(e) = write_prompt(&mut stdout).await {
81-
log::error!("shell: failed to redraw prompt: {e}");
82-
return Ok(());
83-
}
84-
}
85-
}
86-
next = reader.next_line() => {
87-
match next {
88-
Ok(Some(l)) => break l,
89-
Ok(None) => {
90-
// EOF (Ctrl-D)
91-
println!();
92-
return Ok(());
93-
}
94-
Err(e) => {
95-
log::error!("shell: failed to read line: {e}");
96-
return Ok(());
97-
}
98-
}
99-
}
70+
// `rustyline::readline` is blocking, so run it on the blocking pool;
71+
// move the editor in and back out so history persists across lines.
72+
let prompt = PROMPT.to_string();
73+
let (editor, readline) = tokio::task::spawn_blocking(move || {
74+
let res = rl.readline(&prompt);
75+
(rl, res)
76+
})
77+
.await?;
78+
rl = editor;
79+
80+
let line = match readline {
81+
Ok(l) => l,
82+
// Ctrl-C: abandon the current line and show a fresh prompt.
83+
Err(ReadlineError::Interrupted) => continue,
84+
// Ctrl-D: exit the shell.
85+
Err(ReadlineError::Eof) => break,
86+
Err(e) => {
87+
eprintln!("shell: input error: {e}");
88+
break;
10089
}
10190
};
10291

10392
let trimmed = line.trim();
10493
if trimmed.is_empty() {
10594
continue;
10695
}
96+
let _ = rl.add_history_entry(trimmed);
97+
10798
if trimmed == "quit" || trimmed == "exit" {
10899
break;
109100
}
@@ -112,14 +103,25 @@ pub async fn run(shell_cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
112103
continue;
113104
}
114105

115-
// Parse the line through the same clap grammar as single-shot mode.
106+
// Tokenise with shell-style quoting so quoted values with spaces are a
107+
// single argument. `shlex::split` returns None on unbalanced quotes.
108+
let tokens = match shlex::split(trimmed) {
109+
Some(t) if !t.is_empty() => t,
110+
Some(_) => continue,
111+
None => {
112+
eprintln!("error: unbalanced quotes in command");
113+
continue;
114+
}
115+
};
116+
116117
// Prepend the binary name so clap's parser is happy.
117-
let argv = std::iter::once("qauld-ctl")
118-
.chain(trimmed.split_whitespace())
118+
let argv = std::iter::once("qauld-ctl".to_string())
119+
.chain(tokens)
119120
.collect::<Vec<_>>();
120121
let parsed = match Cli::try_parse_from(&argv) {
121122
Ok(p) => p,
122123
Err(e) => {
124+
// clap renders help/usage/errors here, including `<cmd> --help`.
123125
eprintln!("{e}");
124126
continue;
125127
}
@@ -146,73 +148,43 @@ pub async fn run(shell_cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
146148
command: parsed.command,
147149
};
148150

149-
let mut transport = match qauld_rpc::SocketTransport::connect(&crate::connect_info(&shell_cli)).await {
150-
Ok(t) => t,
151-
Err(e) => {
152-
eprintln!("connection failed: {e}");
153-
continue;
154-
}
155-
};
151+
let mut transport =
152+
match qauld_rpc::SocketTransport::connect(&crate::connect_info(&shell_cli)).await {
153+
Ok(t) => t,
154+
Err(e) => {
155+
eprintln!("connection failed: {e}");
156+
continue;
157+
}
158+
};
156159
if let Err(e) = crate::run(&mut transport, merged).await {
157160
eprintln!("error: {e}");
158161
}
159162
}
160163

164+
if let Some(path) = history.as_ref() {
165+
let _ = rl.save_history(path);
166+
}
161167
println!("bye");
162168
Ok(())
163169
}
164170

165-
async fn write_prompt(
166-
stdout: &mut tokio::io::Stdout,
167-
) -> Result<(), Box<dyn std::error::Error>> {
168-
stdout.write_all(PROMPT).await?;
169-
stdout.flush().await?;
170-
Ok(())
171-
}
172-
173-
/// Receive the next event from the listener. Returns `None` if the
174-
/// listener was never started (no daemon at shell launch) or has gone
175-
/// away. We never return early on `None` from a live channel; instead
176-
/// we drop the receiver so future iterations skip the event branch.
177-
async fn recv_event(rx: &mut Option<tokio::sync::mpsc::UnboundedReceiver<String>>) -> Option<String> {
178-
match rx {
179-
Some(r) => match r.recv().await {
180-
Some(line) => Some(line),
181-
None => {
182-
// Channel closed; drop the receiver so we don't poll it again.
183-
*rx = None;
184-
None
185-
}
186-
},
187-
None => {
188-
// Park forever so `tokio::select!` always picks the stdin branch.
189-
std::future::pending::<Option<String>>().await
190-
}
191-
}
192-
}
193-
194-
async fn print_async_event(stdout: &mut tokio::io::Stdout, line: &str) {
195-
// Carriage return + clear-rest-of-line (ANSI), then the event, then a
196-
// newline. The clear-rest-of-line keeps the prompt from leaking onto
197-
// the event line in terminals that support it; in terminals that
198-
// don't, the worst case is a stale "qauld> " prefix on the event row.
199-
let payload = format!("\r\x1b[K{line}\n");
200-
let _ = stdout.write_all(payload.as_bytes()).await;
201-
let _ = stdout.flush().await;
171+
/// Where to persist shell history across sessions. `None` (no `$HOME`) just
172+
/// means in-session-only history — arrow-key recall still works.
173+
fn history_path() -> Option<std::path::PathBuf> {
174+
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
175+
Some(std::path::PathBuf::from(home).join(".qauld-ctl_history"))
202176
}
203177

204-
fn print_banner() {
205-
println!("qauld-ctl interactive shell");
206-
println!("Type a qauld-ctl command (without the leading binary name).");
207-
println!("Examples: node info | users list | help | quit");
208-
println!("Live events from qauld will appear in this window as they happen.");
178+
fn print_intro() {
179+
println!("qauld-ctl interactive shell — type a command (no leading binary name).");
180+
println!(" history: ↑/↓ edit line: ←/→ help: help exit: quit / Ctrl-D");
181+
println!("Live events from qauld appear inline as they happen.");
209182
println!();
210183
}
211184

185+
/// Render the real clap help (command list + options) so `help` and the
186+
/// startup screen show the actual grammar rather than a vague pointer.
212187
fn print_help() {
213-
println!("Available commands are the same as qauld-ctl in single-shot mode.");
214-
println!("Run `qauld-ctl --help` outside the shell for the full grammar, or");
215-
println!("type any subcommand with `--help` here to see its options, e.g.");
216-
println!(" users --help");
217-
println!("Quoted arguments with spaces are not yet supported in shell mode.");
188+
let mut cmd = Cli::command();
189+
println!("{}", cmd.render_help());
218190
}

0 commit comments

Comments
 (0)