diff --git a/Cargo.lock b/Cargo.lock index 72934061..4eb3f90d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -351,6 +351,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmake" version = "0.1.57" @@ -366,6 +375,15 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -495,6 +513,27 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "dunce" version = "1.0.5" @@ -577,7 +616,14 @@ dependencies = [ name = "emberkv-cli" version = "0.3.2" dependencies = [ + "bytes", "clap", + "colored", + "dirs", + "ember-protocol", + "rustyline", + "thiserror 2.0.18", + "tokio", ] [[package]] @@ -598,6 +644,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "equivalent" version = "1.0.2" @@ -614,12 +666,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -834,6 +903,15 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1043,6 +1121,16 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1143,6 +1231,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1221,6 +1330,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "5.1.0" @@ -1402,6 +1517,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -1508,6 +1633,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1701,6 +1837,28 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.59.0", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2191,6 +2349,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -2436,6 +2606,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/README.md b/README.md index f4337af5..692b1717 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,13 @@ a low-latency, memory-efficient, distributed cache written in Rust. designed to - **lru eviction** — approximate LRU via random sampling when memory pressure hits - **persistence** — append-only file (AOF) and point-in-time snapshots - **pipelining** — multiple commands per read for high throughput +- **interactive CLI** — `ember-cli` with REPL, tab-completion, inline help, and one-shot mode - **graceful shutdown** — drains active connections on SIGINT/SIGTERM before exiting ## quickstart ```bash -# build +# build server and cli cargo build --release # run the server (defaults to 127.0.0.1:6379) @@ -60,55 +61,48 @@ cargo build --release --tls-cert-file cert.pem --tls-key-file key.pem ``` +ember speaks RESP3, so `redis-cli` works as a drop-in replacement — but `ember-cli` adds tab-completion, inline help, and auto-reconnect. + ```bash -# connect with redis-cli +# ember-cli: interactive REPL with autocomplete and help +ember-cli # starts REPL at 127.0.0.1:6379> +ember-cli -H 10.0.0.1 -p 6380 # connect to a different host +ember-cli -a mypassword # authenticate + +# one-shot mode +ember-cli SET hello world # => OK +ember-cli GET hello # => "hello" + +# redis-cli works too — same protocol, same port redis-cli SET hello world # => OK redis-cli GET hello # => "world" -redis-cli MSET a 1 b 2 c 3 # => OK -redis-cli MGET a b c # => 1) "1" 2) "2" 3) "3" - -# expiration -redis-cli SET temp data EX 60 -redis-cli TTL temp # => 59 -redis-cli PTTL temp # => 59000 -redis-cli PERSIST temp # => (integer) 1 - -# counters -redis-cli SET counter 10 -redis-cli INCR counter # => (integer) 11 -redis-cli DECR counter # => (integer) 10 - -# lists -redis-cli LPUSH mylist a b c # => (integer) 3 -redis-cli LRANGE mylist 0 -1 # => 1) "c" 2) "b" 3) "a" - -# sorted sets -redis-cli ZADD board 100 alice 200 bob -redis-cli ZRANGE board 0 -1 WITHSCORES -redis-cli ZCARD board # => (integer) 2 - -# hashes -redis-cli HSET user:1 name alice age 30 -redis-cli HGET user:1 name # => "alice" -redis-cli HGETALL user:1 # => 1) "name" 2) "alice" 3) "age" 4) "30" -redis-cli HINCRBY user:1 age 1 # => (integer) 31 - -# sets -redis-cli SADD tags rust cache fast # => (integer) 3 -redis-cli SMEMBERS tags # => 1) "cache" 2) "fast" 3) "rust" -redis-cli SISMEMBER tags rust # => (integer) 1 -redis-cli SCARD tags # => (integer) 3 -redis-cli SREM tags fast # => (integer) 1 - -# iteration -redis-cli SCAN 0 MATCH "user:*" COUNT 100 -redis-cli DBSIZE # => (integer) 6 -redis-cli FLUSHDB # => OK - -# TLS connection +``` + +```bash +# everything below works with either ember-cli or redis-cli +SET counter 10 +INCR counter # => (integer) 11 + +LPUSH mylist a b c # => (integer) 3 +LRANGE mylist 0 -1 # => 1) "c" 2) "b" 3) "a" + +ZADD board 100 alice 200 bob +ZRANGE board 0 -1 WITHSCORES + +HSET user:1 name alice age 30 +HGETALL user:1 # => 1) "name" 2) "alice" 3) "age" 4) "30" + +SADD tags rust cache fast # => (integer) 3 +SMEMBERS tags # => 1) "cache" 2) "fast" 3) "rust" + +SET temp data EX 60 +TTL temp # => 59 + +SCAN 0 MATCH "user:*" COUNT 100 +DBSIZE # => (integer) 6 + +# TLS (redis-cli only for now — ember-cli TLS coming soon) redis-cli -p 6380 --tls --insecure PING -# or with cert verification -redis-cli -p 6380 --tls --cacert cert.pem PING ``` ## configuration @@ -164,7 +158,7 @@ helm install ember helm/ember \ # connect via port-forward kubectl port-forward svc/ember 6379:6379 -redis-cli -h 127.0.0.1 +ember-cli ``` see [helm/ember/values.yaml](helm/ember/values.yaml) for all configurable values. @@ -178,7 +172,7 @@ crates/ ember-protocol/ RESP3 wire protocol ember-persistence/ AOF and snapshot durability ember-cluster/ raft consensus, gossip, slot management, migration - ember-cli/ interactive CLI tool + ember-cli/ interactive CLI client (REPL, one-shot, autocomplete) ``` ## architecture diff --git a/crates/ember-cli/Cargo.toml b/crates/ember-cli/Cargo.toml index df36c46d..5c7d1f82 100644 --- a/crates/ember-cli/Cargo.toml +++ b/crates/ember-cli/Cargo.toml @@ -15,3 +15,10 @@ path = "src/main.rs" [dependencies] clap = { workspace = true } +tokio = { workspace = true } +bytes = { workspace = true } +ember-protocol = { workspace = true } +thiserror = { workspace = true } +rustyline = "15" +colored = "3" +dirs = "6" diff --git a/crates/ember-cli/README.md b/crates/ember-cli/README.md index 4c7e09c2..75c45b64 100644 --- a/crates/ember-cli/README.md +++ b/crates/ember-cli/README.md @@ -1,16 +1,56 @@ # emberkv-cli -interactive command-line client for [ember](https://github.com/kacy/ember). +interactive command-line client for [ember](https://github.com/kacy/ember). a thin client that tokenizes input, sends RESP3 frames to the server, and pretty-prints responses — no client-side command validation. -> this crate is a stub — the CLI is planned for phase 5. +## usage -## planned features +```bash +# start interactive REPL (connects to 127.0.0.1:6379) +ember-cli -- interactive REPL with command history -- syntax highlighting and autocomplete -- inline help (`help SET`, `help ZADD`) -- cluster management subcommands -- built-in benchmarking +# connect to a specific host and port +ember-cli -H 10.0.0.1 -p 6380 + +# authenticate +ember-cli -a mypassword + +# one-shot mode — run a single command and exit +ember-cli PING +ember-cli SET greeting hello +ember-cli GET greeting + +# quoted strings work in both modes +ember-cli SET msg "hello world" +``` + +## options + +| flag | default | description | +|------|---------|-------------| +| `-H`, `--host` | 127.0.0.1 | server hostname | +| `-p`, `--port` | 6379 | server port | +| `-a`, `--password` | — | password for AUTH | +| `--tls` | — | enable TLS (not yet supported) | + +## repl features + +- **tab completion** — press tab to autocomplete command names +- **history** — command history persisted to `~/.emberkv_history` +- **inline help** — type `help` for all commands, `help SET` for details +- **reconnection** — automatically reconnects if the server disconnects +- **quoted strings** — double and single quoted arguments with backslash escapes +- **timeouts** — 5s connect timeout, 10s read timeout to avoid hanging + +## local commands + +these are handled by the client and not sent to the server: + +| command | description | +|---------|-------------| +| `help` | show all commands grouped by category | +| `help ` | show usage for a specific command | +| `quit` / `exit` | exit the REPL | +| `clear` | clear the terminal screen | ## related crates diff --git a/crates/ember-cli/src/commands.rs b/crates/ember-cli/src/commands.rs new file mode 100644 index 00000000..87e7d31e --- /dev/null +++ b/crates/ember-cli/src/commands.rs @@ -0,0 +1,519 @@ +//! Static command metadata for autocomplete and inline help. +//! +//! This is a thin client — we never validate commands ourselves. +//! This table only drives tab-completion and the `help` display. + +use std::collections::BTreeMap; + +/// Metadata for a single ember/redis command. +pub struct CommandInfo { + /// Uppercase command name (e.g. "SET"). + pub name: &'static str, + /// Argument synopsis (e.g. "key value [EX seconds] [NX|XX]"). + pub args: &'static str, + /// Functional group for help display. + pub group: &'static str, + /// One-line summary. + pub summary: &'static str, +} + +/// All known commands, sorted alphabetically. +/// +/// Keeping this as a flat array makes autocomplete fast (linear scan +/// over ~80 entries is plenty fast for interactive use) and avoids +/// any initialization cost. +pub static COMMANDS: &[CommandInfo] = &[ + // --- connection --- + CommandInfo { + name: "AUTH", + args: "[username] password", + group: "connection", + summary: "authenticate to the server", + }, + CommandInfo { + name: "ECHO", + args: "message", + group: "connection", + summary: "echo the given string", + }, + CommandInfo { + name: "PING", + args: "[message]", + group: "connection", + summary: "ping the server", + }, + CommandInfo { + name: "QUIT", + args: "", + group: "connection", + summary: "close the connection", + }, + // --- string --- + CommandInfo { + name: "APPEND", + args: "key value", + group: "string", + summary: "append a value to a key", + }, + CommandInfo { + name: "DECR", + args: "key", + group: "string", + summary: "decrement the integer value of a key by one", + }, + CommandInfo { + name: "DECRBY", + args: "key decrement", + group: "string", + summary: "decrement the integer value of a key by the given number", + }, + CommandInfo { + name: "GET", + args: "key", + group: "string", + summary: "get the value of a key", + }, + CommandInfo { + name: "INCR", + args: "key", + group: "string", + summary: "increment the integer value of a key by one", + }, + CommandInfo { + name: "INCRBY", + args: "key increment", + group: "string", + summary: "increment the integer value of a key by the given amount", + }, + CommandInfo { + name: "INCRBYFLOAT", + args: "key increment", + group: "string", + summary: "increment the float value of a key by the given amount", + }, + CommandInfo { + name: "MGET", + args: "key [key ...]", + group: "string", + summary: "get the values of all the given keys", + }, + CommandInfo { + name: "MSET", + args: "key value [key value ...]", + group: "string", + summary: "set multiple keys to multiple values", + }, + CommandInfo { + name: "SET", + args: "key value [EX seconds | PX milliseconds] [NX|XX]", + group: "string", + summary: "set the string value of a key", + }, + CommandInfo { + name: "STRLEN", + args: "key", + group: "string", + summary: "get the length of the value stored at a key", + }, + // --- generic --- + CommandInfo { + name: "DEL", + args: "key [key ...]", + group: "generic", + summary: "delete one or more keys", + }, + CommandInfo { + name: "EXISTS", + args: "key [key ...]", + group: "generic", + summary: "determine if a key exists", + }, + CommandInfo { + name: "EXPIRE", + args: "key seconds", + group: "generic", + summary: "set a key's time to live in seconds", + }, + CommandInfo { + name: "KEYS", + args: "pattern", + group: "generic", + summary: "find all keys matching the given pattern", + }, + CommandInfo { + name: "PERSIST", + args: "key", + group: "generic", + summary: "remove the expiration from a key", + }, + CommandInfo { + name: "PEXPIRE", + args: "key milliseconds", + group: "generic", + summary: "set a key's time to live in milliseconds", + }, + CommandInfo { + name: "PTTL", + args: "key", + group: "generic", + summary: "get the time to live for a key in milliseconds", + }, + CommandInfo { + name: "RENAME", + args: "key newkey", + group: "generic", + summary: "rename a key", + }, + CommandInfo { + name: "SCAN", + args: "cursor [MATCH pattern] [COUNT count]", + group: "generic", + summary: "incrementally iterate the keys space", + }, + CommandInfo { + name: "TTL", + args: "key", + group: "generic", + summary: "get the time to live for a key in seconds", + }, + CommandInfo { + name: "TYPE", + args: "key", + group: "generic", + summary: "determine the type stored at key", + }, + CommandInfo { + name: "UNLINK", + args: "key [key ...]", + group: "generic", + summary: "delete keys asynchronously", + }, + // --- list --- + CommandInfo { + name: "LLEN", + args: "key", + group: "list", + summary: "get the length of a list", + }, + CommandInfo { + name: "LPOP", + args: "key", + group: "list", + summary: "remove and return the first element of a list", + }, + CommandInfo { + name: "LPUSH", + args: "key value [value ...]", + group: "list", + summary: "prepend one or more values to a list", + }, + CommandInfo { + name: "LRANGE", + args: "key start stop", + group: "list", + summary: "get a range of elements from a list", + }, + CommandInfo { + name: "RPOP", + args: "key", + group: "list", + summary: "remove and return the last element of a list", + }, + CommandInfo { + name: "RPUSH", + args: "key value [value ...]", + group: "list", + summary: "append one or more values to a list", + }, + // --- hash --- + CommandInfo { + name: "HDEL", + args: "key field [field ...]", + group: "hash", + summary: "delete one or more hash fields", + }, + CommandInfo { + name: "HEXISTS", + args: "key field", + group: "hash", + summary: "determine if a hash field exists", + }, + CommandInfo { + name: "HGET", + args: "key field", + group: "hash", + summary: "get the value of a hash field", + }, + CommandInfo { + name: "HGETALL", + args: "key", + group: "hash", + summary: "get all fields and values in a hash", + }, + CommandInfo { + name: "HINCRBY", + args: "key field increment", + group: "hash", + summary: "increment the integer value of a hash field", + }, + CommandInfo { + name: "HKEYS", + args: "key", + group: "hash", + summary: "get all field names in a hash", + }, + CommandInfo { + name: "HLEN", + args: "key", + group: "hash", + summary: "get the number of fields in a hash", + }, + CommandInfo { + name: "HMGET", + args: "key field [field ...]", + group: "hash", + summary: "get the values of multiple hash fields", + }, + CommandInfo { + name: "HSET", + args: "key field value [field value ...]", + group: "hash", + summary: "set the value of a hash field", + }, + CommandInfo { + name: "HVALS", + args: "key", + group: "hash", + summary: "get all values in a hash", + }, + // --- set --- + CommandInfo { + name: "SADD", + args: "key member [member ...]", + group: "set", + summary: "add one or more members to a set", + }, + CommandInfo { + name: "SCARD", + args: "key", + group: "set", + summary: "get the number of members in a set", + }, + CommandInfo { + name: "SISMEMBER", + args: "key member", + group: "set", + summary: "determine if a value is a member of a set", + }, + CommandInfo { + name: "SMEMBERS", + args: "key", + group: "set", + summary: "get all members in a set", + }, + CommandInfo { + name: "SREM", + args: "key member [member ...]", + group: "set", + summary: "remove one or more members from a set", + }, + // --- sorted set --- + CommandInfo { + name: "ZADD", + args: "key [NX|XX] [GT|LT] [CH] score member [score member ...]", + group: "sorted_set", + summary: "add one or more members to a sorted set", + }, + CommandInfo { + name: "ZCARD", + args: "key", + group: "sorted_set", + summary: "get the number of members in a sorted set", + }, + CommandInfo { + name: "ZRANGE", + args: "key start stop [WITHSCORES]", + group: "sorted_set", + summary: "return a range of members by index", + }, + CommandInfo { + name: "ZRANK", + args: "key member", + group: "sorted_set", + summary: "determine the index of a member in a sorted set", + }, + CommandInfo { + name: "ZREM", + args: "key member [member ...]", + group: "sorted_set", + summary: "remove one or more members from a sorted set", + }, + CommandInfo { + name: "ZSCORE", + args: "key member", + group: "sorted_set", + summary: "get the score of a member in a sorted set", + }, + // --- server --- + CommandInfo { + name: "BGREWRITEAOF", + args: "", + group: "server", + summary: "asynchronously rewrite the append-only file", + }, + CommandInfo { + name: "BGSAVE", + args: "", + group: "server", + summary: "asynchronously save the dataset to disk", + }, + CommandInfo { + name: "DBSIZE", + args: "", + group: "server", + summary: "return the number of keys in the database", + }, + CommandInfo { + name: "FLUSHDB", + args: "[ASYNC]", + group: "server", + summary: "remove all keys from the current database", + }, + CommandInfo { + name: "INFO", + args: "[section]", + group: "server", + summary: "get information and statistics about the server", + }, + CommandInfo { + name: "SLOWLOG", + args: "GET [count] | LEN | RESET", + group: "server", + summary: "manage the slow log", + }, + // --- pubsub --- + CommandInfo { + name: "PSUBSCRIBE", + args: "pattern [pattern ...]", + group: "pubsub", + summary: "listen for messages published to channels matching patterns", + }, + CommandInfo { + name: "PUBLISH", + args: "channel message", + group: "pubsub", + summary: "post a message to a channel", + }, + CommandInfo { + name: "PUBSUB", + args: "CHANNELS [pattern] | NUMSUB [channel ...] | NUMPAT", + group: "pubsub", + summary: "inspect the pub/sub subsystem", + }, + CommandInfo { + name: "PUNSUBSCRIBE", + args: "[pattern [pattern ...]]", + group: "pubsub", + summary: "stop listening for messages matching patterns", + }, + CommandInfo { + name: "SUBSCRIBE", + args: "channel [channel ...]", + group: "pubsub", + summary: "listen for messages published to channels", + }, + CommandInfo { + name: "UNSUBSCRIBE", + args: "[channel [channel ...]]", + group: "pubsub", + summary: "stop listening for messages on channels", + }, + // --- cluster --- + CommandInfo { + name: "ASKING", + args: "", + group: "cluster", + summary: "signal that the next command is for a migrating slot", + }, + CommandInfo { + name: "CLUSTER", + args: "subcommand [args...]", + group: "cluster", + summary: "cluster management commands", + }, + CommandInfo { + name: "MIGRATE", + args: "host port key db timeout [COPY] [REPLACE]", + group: "cluster", + summary: "atomically transfer a key to another server", + }, +]; + +/// Look up a command by name (case-insensitive). +pub fn find_command(name: &str) -> Option<&'static CommandInfo> { + let upper = name.to_uppercase(); + COMMANDS.iter().find(|c| c.name == upper) +} + +/// Returns all known command names for autocomplete. +pub fn command_names() -> Vec<&'static str> { + COMMANDS.iter().map(|c| c.name).collect() +} + +/// Groups commands by their functional group for help display. +pub fn commands_by_group() -> BTreeMap<&'static str, Vec<&'static CommandInfo>> { + let mut groups = BTreeMap::new(); + for cmd in COMMANDS { + groups.entry(cmd.group).or_insert_with(Vec::new).push(cmd); + } + groups +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn find_command_case_insensitive() { + assert!(find_command("set").is_some()); + assert!(find_command("SET").is_some()); + assert!(find_command("Set").is_some()); + assert_eq!(find_command("set").map(|c| c.name), Some("SET")); + } + + #[test] + fn find_unknown_command() { + assert!(find_command("NOTACOMMAND").is_none()); + } + + #[test] + fn command_names_not_empty() { + let names = command_names(); + assert!(!names.is_empty()); + assert!(names.contains(&"GET")); + assert!(names.contains(&"SET")); + } + + #[test] + fn groups_cover_all_commands() { + let groups = commands_by_group(); + let total: usize = groups.values().map(|v| v.len()).sum(); + assert_eq!(total, COMMANDS.len()); + } + + #[test] + fn commands_sorted_within_groups() { + // each group's commands should be alphabetically sorted + let groups = commands_by_group(); + for (group_name, cmds) in &groups { + for i in 1..cmds.len() { + assert!( + cmds[i - 1].name <= cmds[i].name, + "commands in group '{group_name}' not sorted: {} > {}", + cmds[i - 1].name, + cmds[i].name, + ); + } + } + } +} diff --git a/crates/ember-cli/src/connection.rs b/crates/ember-cli/src/connection.rs new file mode 100644 index 00000000..1af73007 --- /dev/null +++ b/crates/ember-cli/src/connection.rs @@ -0,0 +1,159 @@ +//! Async TCP connection to an ember server. +//! +//! Handles connecting, sending commands as RESP3 arrays, +//! and reading back parsed frames. + +use std::time::Duration; + +use bytes::BytesMut; +use ember_protocol::parse::parse_frame; +use ember_protocol::types::Frame; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Maximum read buffer size (64 KiB). Prevents unbounded memory growth +/// if the server sends a response that never completes. +const MAX_READ_BUF: usize = 64 * 1024; + +/// Default timeout for connecting to the server. +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Default timeout for reading a response from the server. +const READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// Errors that can occur during connection operations. +#[derive(Debug, thiserror::Error)] +pub enum ConnectionError { + #[error("connection failed: {0}")] + Io(#[from] std::io::Error), + + #[error("protocol error: {0}")] + Protocol(String), + + #[error("server disconnected")] + Disconnected, + + #[error("authentication failed: {0}")] + AuthFailed(String), + + #[error("connection timed out")] + Timeout, + + #[error("response too large (exceeded {MAX_READ_BUF} bytes)")] + ResponseTooLarge, +} + +/// A TCP connection to an ember server with read/write buffering. +pub struct Connection { + stream: TcpStream, + read_buf: BytesMut, + write_buf: BytesMut, +} + +impl Connection { + /// Connects to an ember server at the given host and port. + /// + /// Times out after 5 seconds if the server is unreachable. + pub async fn connect(host: &str, port: u16) -> Result { + let stream = tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect((host, port))) + .await + .map_err(|_| ConnectionError::Timeout)? + .map_err(ConnectionError::Io)?; + + Ok(Self { + stream, + read_buf: BytesMut::with_capacity(4096), + write_buf: BytesMut::with_capacity(4096), + }) + } + + /// Sends a command (as a list of string tokens) and reads the response. + /// + /// Tokens are serialized as a RESP3 array of bulk strings, which is + /// the standard client -> server wire format. + pub async fn send_command(&mut self, tokens: &[String]) -> Result { + // build a RESP3 array of bulk strings + let parts: Vec = tokens + .iter() + .map(|t| Frame::Bulk(bytes::Bytes::from(t.clone()))) + .collect(); + let frame = Frame::Array(parts); + + // serialize into the write buffer and flush + self.write_buf.clear(); + frame.serialize(&mut self.write_buf); + self.stream.write_all(&self.write_buf).await?; + self.stream.flush().await?; + + // read the response + self.read_response().await + } + + /// Authenticates with the server using the AUTH command. + pub async fn authenticate(&mut self, password: &str) -> Result<(), ConnectionError> { + let tokens = vec!["AUTH".to_string(), password.to_string()]; + let response = self.send_command(&tokens).await?; + + match &response { + Frame::Simple(s) if s == "OK" => Ok(()), + Frame::Error(e) => Err(ConnectionError::AuthFailed(e.clone())), + _ => Err(ConnectionError::AuthFailed( + "unexpected response to AUTH".into(), + )), + } + } + + /// Gracefully shuts down the connection. + /// + /// Sends a QUIT command to the server and then shuts down the TCP stream. + /// Errors are intentionally ignored — this is best-effort cleanup. + pub async fn shutdown(&mut self) { + // try to send QUIT so the server can clean up + let quit = Frame::Array(vec![Frame::Bulk(bytes::Bytes::from_static(b"QUIT"))]); + self.write_buf.clear(); + quit.serialize(&mut self.write_buf); + let _ = self.stream.write_all(&self.write_buf).await; + let _ = self.stream.flush().await; + + // graceful TCP shutdown (sends FIN instead of RST) + let _ = self.stream.shutdown().await; + } + + /// Reads a complete RESP3 frame from the server. + async fn read_response(&mut self) -> Result { + loop { + // try to parse a frame from what we have + if !self.read_buf.is_empty() { + match parse_frame(&self.read_buf) { + Ok(Some((frame, consumed))) => { + // remove the consumed bytes from the buffer + let _ = self.read_buf.split_to(consumed); + return Ok(frame); + } + Ok(None) => { + // incomplete — need more data + } + Err(e) => { + return Err(ConnectionError::Protocol(e.to_string())); + } + } + } + + // guard against unbounded buffer growth + if self.read_buf.len() >= MAX_READ_BUF { + return Err(ConnectionError::ResponseTooLarge); + } + + // read more data from the socket with a timeout + let read_result = + tokio::time::timeout(READ_TIMEOUT, self.stream.read_buf(&mut self.read_buf)).await; + + match read_result { + Ok(Ok(0)) => return Err(ConnectionError::Disconnected), + Ok(Ok(_)) => {} // got data, loop back to parse + Ok(Err(e)) => return Err(ConnectionError::Io(e)), + Err(_) => return Err(ConnectionError::Timeout), + } + } + } +} diff --git a/crates/ember-cli/src/format.rs b/crates/ember-cli/src/format.rs new file mode 100644 index 00000000..2b23ce5c --- /dev/null +++ b/crates/ember-cli/src/format.rs @@ -0,0 +1,190 @@ +//! Pretty-printing for RESP3 frames. +//! +//! Converts server responses into colorized, human-readable output +//! matching the style familiar to redis-cli users. + +use colored::Colorize; +use ember_protocol::types::Frame; + +/// Formats a RESP3 frame for terminal display. +/// +/// Output style matches redis-cli conventions: +/// - simple strings: green +/// - errors: red with `(error)` prefix +/// - integers: yellow with `(integer)` prefix +/// - bulk strings: green, quoted (unless multiline) +/// - nil: dim `(nil)` +/// - arrays: numbered list +/// - maps: key => value pairs +pub fn format_response(frame: &Frame) -> String { + format_frame(frame, 0) +} + +fn format_frame(frame: &Frame, indent: usize) -> String { + let prefix = " ".repeat(indent); + + match frame { + Frame::Simple(s) => format!("{prefix}{}", s.green()), + + Frame::Error(e) => format!("{prefix}{} {}", "(error)".red(), e.red()), + + Frame::Integer(n) => format!( + "{prefix}{} {}", + "(integer)".yellow(), + n.to_string().yellow() + ), + + Frame::Bulk(data) => { + match std::str::from_utf8(data) { + Ok(s) if s.contains("\r\n") || s.contains('\n') => { + // multiline output (like INFO) — print unquoted + format!("{prefix}{}", s.green()) + } + Ok(s) => format!("{prefix}{}", format!("\"{}\"", s).green()), + Err(_) => { + // binary data — show as hex + let hex: String = data.iter().map(|b| format!("{b:02x}")).collect(); + format!("{prefix}{}", hex.green()) + } + } + } + + Frame::Null => format!("{prefix}{}", "(nil)".dimmed()), + + Frame::Array(items) if items.is_empty() => { + format!("{prefix}{}", "(empty array)".dimmed()) + } + + Frame::Array(items) => { + let mut lines = Vec::with_capacity(items.len()); + for (i, item) in items.iter().enumerate() { + let num = format!("{})", i + 1); + let formatted = format_frame(item, 0); + lines.push(format!("{prefix}{} {}", num, formatted)); + } + lines.join("\n") + } + + Frame::Map(pairs) if pairs.is_empty() => { + format!("{prefix}{}", "(empty map)".dimmed()) + } + + Frame::Map(pairs) => { + let mut lines = Vec::with_capacity(pairs.len()); + for (i, (key, val)) in pairs.iter().enumerate() { + let num = format!("{})", i + 1); + let key_str = format_frame(key, 0); + let val_str = format_frame(val, 0); + lines.push(format!("{prefix}{num} {key_str} => {val_str}")); + } + lines.join("\n") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::Bytes; + + // disable colors for deterministic test output + fn no_color String>(f: F) -> String { + colored::control::set_override(false); + let result = f(); + colored::control::unset_override(); + result + } + + #[test] + fn format_simple_string() { + let out = no_color(|| format_response(&Frame::Simple("OK".into()))); + assert_eq!(out, "OK"); + } + + #[test] + fn format_error() { + let out = no_color(|| format_response(&Frame::Error("ERR unknown command".into()))); + assert_eq!(out, "(error) ERR unknown command"); + } + + #[test] + fn format_integer() { + let out = no_color(|| format_response(&Frame::Integer(42))); + assert_eq!(out, "(integer) 42"); + } + + #[test] + fn format_negative_integer() { + let out = no_color(|| format_response(&Frame::Integer(-1))); + assert_eq!(out, "(integer) -1"); + } + + #[test] + fn format_bulk_string() { + let out = no_color(|| format_response(&Frame::Bulk(Bytes::from_static(b"hello")))); + assert_eq!(out, "\"hello\""); + } + + #[test] + fn format_bulk_multiline() { + let out = no_color(|| format_response(&Frame::Bulk(Bytes::from_static(b"line1\r\nline2")))); + assert_eq!(out, "line1\r\nline2"); + } + + #[test] + fn format_bulk_binary() { + let out = no_color(|| format_response(&Frame::Bulk(Bytes::from_static(&[0xff, 0x00])))); + assert_eq!(out, "ff00"); + } + + #[test] + fn format_null() { + let out = no_color(|| format_response(&Frame::Null)); + assert_eq!(out, "(nil)"); + } + + #[test] + fn format_empty_array() { + let out = no_color(|| format_response(&Frame::Array(vec![]))); + assert_eq!(out, "(empty array)"); + } + + #[test] + fn format_array() { + let out = no_color(|| { + format_response(&Frame::Array(vec![ + Frame::Bulk(Bytes::from_static(b"foo")), + Frame::Bulk(Bytes::from_static(b"bar")), + ])) + }); + assert_eq!(out, "1) \"foo\"\n2) \"bar\""); + } + + #[test] + fn format_array_with_nil() { + let out = no_color(|| { + format_response(&Frame::Array(vec![ + Frame::Bulk(Bytes::from_static(b"hello")), + Frame::Null, + ])) + }); + assert_eq!(out, "1) \"hello\"\n2) (nil)"); + } + + #[test] + fn format_empty_map() { + let out = no_color(|| format_response(&Frame::Map(vec![]))); + assert_eq!(out, "(empty map)"); + } + + #[test] + fn format_map() { + let out = no_color(|| { + format_response(&Frame::Map(vec![( + Frame::Simple("key".into()), + Frame::Integer(1), + )])) + }); + assert_eq!(out, "1) key => (integer) 1"); + } +} diff --git a/crates/ember-cli/src/main.rs b/crates/ember-cli/src/main.rs index 3e303a61..bd40950b 100644 --- a/crates/ember-cli/src/main.rs +++ b/crates/ember-cli/src/main.rs @@ -1,3 +1,109 @@ -fn main() { - println!("ember cli"); +//! ember-cli: interactive command-line client for ember. +//! +//! Connects to an ember server over TCP, sends commands as RESP3 frames, +//! and pretty-prints responses. Supports both one-shot and interactive +//! (REPL) modes. + +mod commands; +mod connection; +mod format; +mod repl; + +use std::process::ExitCode; + +use clap::Parser; +use colored::Colorize; + +/// Interactive CLI client for ember. +#[derive(Parser)] +#[command(name = "ember-cli", version, about)] +struct Args { + /// Server hostname. + #[arg(short = 'H', long, default_value = "127.0.0.1")] + host: String, + + /// Server port. + #[arg(short, long, default_value_t = 6379)] + port: u16, + + /// Password for AUTH. + #[arg(short = 'a', long)] + password: Option, + + /// Enable TLS (not yet supported). + #[arg(long)] + tls: bool, + + /// Command to execute (one-shot mode). If omitted, starts the REPL. + #[arg(trailing_var_arg = true)] + command: Vec, +} + +fn main() -> ExitCode { + let args = Args::parse(); + + if args.tls { + eprintln!("{}", "tls is not yet supported".yellow()); + return ExitCode::FAILURE; + } + + if args.command.is_empty() { + // interactive REPL mode + repl::run_repl(&args.host, args.port, args.password.as_deref(), args.tls); + ExitCode::SUCCESS + } else { + // one-shot mode: send a single command and exit + run_oneshot( + &args.host, + args.port, + args.password.as_deref(), + &args.command, + ) + } +} + +/// Sends a single command and prints the response. +fn run_oneshot(host: &str, port: u16, password: Option<&str>, command: &[String]) -> ExitCode { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("{}", format!("failed to create runtime: {e}").red()); + return ExitCode::FAILURE; + } + }; + + rt.block_on(async { + let mut conn = match connection::Connection::connect(host, port).await { + Ok(c) => c, + Err(e) => { + eprintln!( + "{}", + format!("could not connect to {host}:{port}: {e}").red() + ); + return ExitCode::FAILURE; + } + }; + + if let Some(pw) = password { + if let Err(e) = conn.authenticate(pw).await { + eprintln!("{}", format!("authentication failed: {e}").red()); + conn.shutdown().await; + return ExitCode::FAILURE; + } + } + + let exit_code = match conn.send_command(command).await { + Ok(frame) => { + println!("{}", format::format_response(&frame)); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("{}", format!("error: {e}").red()); + ExitCode::FAILURE + } + }; + + conn.shutdown().await; + exit_code + }) } diff --git a/crates/ember-cli/src/repl.rs b/crates/ember-cli/src/repl.rs new file mode 100644 index 00000000..582dbdbc --- /dev/null +++ b/crates/ember-cli/src/repl.rs @@ -0,0 +1,441 @@ +//! Interactive REPL for ember. +//! +//! Uses rustyline for readline editing, history, and tab-completion. +//! Commands are tokenized and sent as raw RESP3 arrays — no client-side +//! validation. The server handles everything. + +use std::borrow::Cow; +use std::io::Write; +use std::path::PathBuf; + +use colored::Colorize; +use rustyline::completion::{Completer, Pair}; +use rustyline::error::ReadlineError; +use rustyline::highlight::Highlighter; +use rustyline::hint::Hinter; +use rustyline::validate::Validator; +use rustyline::{CompletionType, Config, Context, Editor, Helper}; + +use crate::commands::{command_names, commands_by_group, find_command}; +use crate::connection::{Connection, ConnectionError}; +use crate::format::format_response; + +/// Runs the interactive REPL loop. +/// +/// Blocks the calling thread. Uses `tokio::runtime::Runtime` internally +/// because rustyline needs the main thread for terminal I/O. +pub fn run_repl(host: &str, port: u16, password: Option<&str>, tls: bool) { + if tls { + eprintln!("{}", "tls is not yet supported".yellow()); + return; + } + + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + eprintln!("{}", format!("failed to create runtime: {e}").red()); + return; + } + }; + + // connect to server + let mut conn = match rt.block_on(Connection::connect(host, port)) { + Ok(c) => c, + Err(e) => { + eprintln!( + "{}", + format!("could not connect to {host}:{port}: {e}").red() + ); + return; + } + }; + + // authenticate if needed + if let Some(pw) = password { + if let Err(e) = rt.block_on(conn.authenticate(pw)) { + eprintln!("{}", format!("authentication failed: {e}").red()); + rt.block_on(conn.shutdown()); + return; + } + } + + // set up rustyline + let config = Config::builder() + .completion_type(CompletionType::List) + .build(); + + let mut rl = match Editor::with_config(config) { + Ok(editor) => editor, + Err(e) => { + eprintln!("{}", format!("failed to create editor: {e}").red()); + rt.block_on(conn.shutdown()); + return; + } + }; + rl.set_helper(Some(EmberHelper)); + + let history_path = history_file(); + if let Some(ref path) = history_path { + let _ = rl.load_history(path); + } + + let prompt = format!("{host}:{port}> "); + + loop { + match rl.readline(&prompt) { + Ok(line) => { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let _ = rl.add_history_entry(trimmed); + + // handle local commands + let first_word = trimmed.split_whitespace().next().unwrap_or(""); + match first_word.to_lowercase().as_str() { + "quit" | "exit" => break, + "clear" => { + print!("\x1B[2J\x1B[1;1H"); + let _ = std::io::stdout().flush(); + continue; + } + "help" => { + handle_help(trimmed); + continue; + } + _ => {} + } + + // tokenize and send to server + let tokens = match tokenize(trimmed) { + Ok(t) => t, + Err(e) => { + eprintln!("{}", format!("parse error: {e}").red()); + continue; + } + }; + + if tokens.is_empty() { + continue; + } + + match rt.block_on(conn.send_command(&tokens)) { + Ok(frame) => { + println!("{}", format_response(&frame)); + } + Err(ConnectionError::Disconnected) => { + eprintln!("{}", "server disconnected, reconnecting...".yellow()); + match rt.block_on(reconnect(host, port, password)) { + Ok(new_conn) => { + conn = new_conn; + eprintln!("{}", "reconnected".green()); + } + Err(e) => { + eprintln!("{}", format!("reconnection failed: {e}").red()); + break; + } + } + } + Err(e) => { + eprintln!("{}", format!("error: {e}").red()); + } + } + } + Err(ReadlineError::Interrupted) => { + // Ctrl-C — just show a new prompt + continue; + } + Err(ReadlineError::Eof) => { + // Ctrl-D — exit + break; + } + Err(e) => { + eprintln!("{}", format!("readline error: {e}").red()); + break; + } + } + } + + if let Some(ref path) = history_path { + let _ = rl.save_history(path); + } + + // graceful shutdown — send QUIT and close the TCP stream + rt.block_on(conn.shutdown()); +} + +/// Establishes a new connection, authenticating if a password is provided. +async fn reconnect( + host: &str, + port: u16, + password: Option<&str>, +) -> Result { + let mut conn = Connection::connect(host, port).await?; + if let Some(pw) = password { + conn.authenticate(pw).await?; + } + Ok(conn) +} + +/// Handles the `help` local command. +fn handle_help(input: &str) { + let parts: Vec<&str> = input.split_whitespace().collect(); + + if parts.len() >= 2 { + // help for a specific command + let name = parts[1]; + match find_command(name) { + Some(cmd) => { + println!( + " {} {}\n {}\n group: {}", + cmd.name.bold(), + cmd.args.dimmed(), + cmd.summary, + cmd.group, + ); + } + None => { + println!("unknown command '{}'. try just 'help' for a list.", name); + } + } + return; + } + + // full help listing + println!("{}", "ember commands:".bold()); + println!(); + for (group, cmds) in commands_by_group() { + println!(" {}:", group.bold()); + for cmd in cmds { + println!(" {:<20} {}", cmd.name, cmd.summary.dimmed()); + } + println!(); + } + println!( + "type {} for details on a specific command.", + "help ".bold() + ); +} + +/// Returns the path to the history file. +fn history_file() -> Option { + dirs::home_dir().map(|home| home.join(".emberkv_history")) +} + +// ----------------------------------------------------------------------- +// tokenizer +// ----------------------------------------------------------------------- + +/// Tokenizes a command string into individual arguments. +/// +/// Handles double-quoted and single-quoted strings with backslash +/// escapes inside double quotes. Unquoted tokens are split on whitespace. +/// +/// # Errors +/// +/// Returns an error on unmatched quotes. +pub fn tokenize(input: &str) -> Result, String> { + let mut tokens = Vec::new(); + let mut current = String::new(); + let mut in_token = false; + let mut chars = input.chars().peekable(); + + while let Some(&ch) = chars.peek() { + match ch { + ' ' | '\t' => { + if in_token { + tokens.push(std::mem::take(&mut current)); + in_token = false; + } + chars.next(); + } + '"' => { + in_token = true; + chars.next(); // consume opening quote + loop { + match chars.next() { + None => return Err("unmatched double quote".into()), + Some('"') => break, + Some('\\') => match chars.next() { + Some(escaped) => current.push(escaped), + None => return Err("trailing backslash".into()), + }, + Some(c) => current.push(c), + } + } + } + '\'' => { + in_token = true; + chars.next(); // consume opening quote + loop { + match chars.next() { + None => return Err("unmatched single quote".into()), + Some('\'') => break, + Some(c) => current.push(c), + } + } + } + _ => { + in_token = true; + current.push(ch); + chars.next(); + } + } + } + + if in_token { + tokens.push(current); + } + + Ok(tokens) +} + +// ----------------------------------------------------------------------- +// rustyline helper (completer + stubs) +// ----------------------------------------------------------------------- + +struct EmberHelper; + +impl Helper for EmberHelper {} + +impl Completer for EmberHelper { + type Candidate = Pair; + + fn complete( + &self, + line: &str, + pos: usize, + _ctx: &Context<'_>, + ) -> rustyline::Result<(usize, Vec)> { + // only complete the first token (command name) + let prefix = &line[..pos]; + if prefix.contains(' ') { + return Ok((pos, vec![])); + } + + let upper = prefix.to_uppercase(); + let matches: Vec = command_names() + .into_iter() + .filter(|name| name.starts_with(&upper)) + .map(|name| Pair { + display: name.to_string(), + replacement: format!("{name} "), + }) + .collect(); + + Ok((0, matches)) + } +} + +impl Hinter for EmberHelper { + type Hint = String; + + fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option { + None + } +} + +impl Highlighter for EmberHelper { + fn highlight_prompt<'b, 's: 'b, 'p: 'b>( + &'s self, + prompt: &'p str, + _default: bool, + ) -> Cow<'b, str> { + Cow::Borrowed(prompt) + } +} + +impl Validator for EmberHelper {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tokenize_simple() { + assert_eq!(tokenize("SET foo bar").unwrap(), vec!["SET", "foo", "bar"],); + } + + #[test] + fn tokenize_extra_whitespace() { + assert_eq!(tokenize(" GET key ").unwrap(), vec!["GET", "key"],); + } + + #[test] + fn tokenize_double_quotes() { + assert_eq!( + tokenize(r#"SET key "hello world""#).unwrap(), + vec!["SET", "key", "hello world"], + ); + } + + #[test] + fn tokenize_single_quotes() { + assert_eq!( + tokenize("SET key 'hello world'").unwrap(), + vec!["SET", "key", "hello world"], + ); + } + + #[test] + fn tokenize_escaped_quote() { + assert_eq!( + tokenize(r#"SET key "say \"hi\"""#).unwrap(), + vec!["SET", "key", r#"say "hi""#], + ); + } + + #[test] + fn tokenize_backslash_in_double_quotes() { + assert_eq!( + tokenize(r#"SET key "a\\b""#).unwrap(), + vec!["SET", "key", r"a\b"], + ); + } + + #[test] + fn tokenize_single_quotes_no_escaping() { + // single quotes are literal — no backslash processing + assert_eq!( + tokenize(r"SET key 'a\b'").unwrap(), + vec!["SET", "key", r"a\b"], + ); + } + + #[test] + fn tokenize_empty_quoted_string() { + assert_eq!(tokenize(r#"SET key """#).unwrap(), vec!["SET", "key", ""],); + } + + #[test] + fn tokenize_unmatched_double_quote() { + assert!(tokenize(r#"SET key "hello"#).is_err()); + } + + #[test] + fn tokenize_unmatched_single_quote() { + assert!(tokenize("SET key 'hello").is_err()); + } + + #[test] + fn tokenize_empty_input() { + assert_eq!(tokenize("").unwrap(), Vec::::new()); + } + + #[test] + fn tokenize_whitespace_only() { + assert_eq!(tokenize(" ").unwrap(), Vec::::new()); + } + + #[test] + fn tokenize_adjacent_quoted_and_unquoted() { + // "foo"bar should produce "foobar" — quotes don't force token boundaries + assert_eq!(tokenize(r#""foo"bar"#).unwrap(), vec!["foobar"],); + } + + #[test] + fn tokenize_tabs() { + assert_eq!(tokenize("GET\tkey").unwrap(), vec!["GET", "key"],); + } +}