This project is a small Rust-based key-value store designed for learning how a database works at the most basic level.
It should:
- accept commands from the terminal
- parse those commands into structured operations
- store data in memory using
HashMap - save data to a file
- load data back from a file
- support startup config via flags, env vars, and config file
- print clear output to the terminal
This is a good project because it stays small enough to finish, but still teaches real systems concepts:
- command parsing
- ownership and borrowing
- mutable state management
- file I/O
- serialization and deserialization
- error handling
- project structure in Rust
It is also a strong foundation for a future TCP server version.
You can use this project in two ways:
- as a CLI (
cargo run) - as a Rust library dependency
- as a RESP-compatible TCP server (
cargo run --bin redis-lite-server)
Start server mode on default Redis port:
cargo run --bin redis-lite-server -- --bind 127.0.0.1:6379 --autoload --autosave --appendonly
With password protection enabled:
cargo run --bin redis-lite-server -- --bind 127.0.0.1:6379 --autoload --autosave --appendonly --requirepass secret
With command-level ACL enabled:
cargo run --bin redis-lite-server -- --bind 127.0.0.1:6379 --autoload --autosave --appendonly \
--acl-rule "default nopass +@read" \
--acl-rule "writer writerpass +@read +@write" \
--acl-rule "admin adminpass +@all"
Quick protocol test using netcat:
printf '*1\r\n$4\r\nPING\r\n' | nc 127.0.0.1 6379
Current RESP commands in server mode:
PING [message]ECHO <message>AUTH <password>AUTH <username> <password>SET <key> <value>GET <key>DEL <key> [key ...]ROLEINFO [section]MULTIEXECDISCARDACLWHOAMIACLCAT [category]ACLLISTSAVE [file]QUIT
Persistence behavior in current server mode:
- Snapshot (RDB-like JSON) with atomic writes
- AOF command logging for mutating commands (
SET,DEL) - Startup recovery order: snapshot first, then AOF replay
Security behavior in current server mode:
- Optional password auth via
--requirepass <password> - When enabled, write/admin commands return
NOAUTHuntilAUTHsucceeds - Optional multi-user ACL rules via repeatable
--acl-ruleflags - ACL categories:
@read,@write,@admin, and@all - In ACL mode, authenticated users receive
NOPERMfor commands outside their category grants ACLWHOAMIreports the current authenticated userACLCATlists categories or the commands inside one categoryACLLISTshows configured users with masked passwords- Transactions are per-connection (
MULTI/EXEC/DISCARD) - While inside
MULTI, queueable commands returnQUEUEDand execute only atEXEC - Graceful shutdown on
Ctrl+Cwith final snapshot save when--autosaveis enabled - Structured logs with request tracing at
--log-level debug
Build image:
docker build -t redis-lite:latest .
Run container:
docker run --rm -p 6379:6379 -v $(pwd)/data:/data redis-lite:latest
In another Rust project Cargo.toml, add a path or git dependency.
Path dependency example:
[dependencies]
redis-lite = { path = "../redis-lite" }use redis_lite::{RedisLite, RuntimeMessage};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut app = RedisLite::new();
if let Some(RuntimeMessage::Continue(message)) = app.execute_line("SET project api")? {
println!("{message}");
}
if let Some(RuntimeMessage::Continue(message)) = app.execute_line("GET project")? {
println!("value: {message}");
}
Ok(())
}execute_line(...) returns:
Ok(None)for empty inputOk(Some(RuntimeMessage::Continue(msg)))for normal outputOk(Some(RuntimeMessage::Exit(msg)))whenEXITis issuedErr(...)for invalid commands and I/O or JSON errors
The CLI supports production-friendly startup options:
--config <file>--data-file <file>--autoload/--no-autoload--autosave/--no-autosave--log-level <level>(error,info,debug)--requirepass <password>--acl-rule <rule>
ACL rule format:
<name> <password|nopass> [+@read|+@write|+@admin|+@all|-@read|-@write|-@admin|-@all] ...
Examples:
default nopass +@read
writer writerpass +@read +@write
admin adminpass +@all
Environment variables are also supported:
REDIS_LITE_CONFIGREDIS_LITE_DATA_FILEREDIS_LITE_AUTOLOADREDIS_LITE_AUTOSAVEREDIS_LITE_LOG_LEVELREDIS_LITE_REQUIREPASSREDIS_LITE_ACL_RULES(semicolon-separated ACL rules)
The best first version is a single-process CLI application with an in-memory store and JSON persistence.
That choice is correct for this stage because:
HashMap<String, String>is the simplest useful store in Rust- JSON is easy to inspect manually during development
serdeandserde_jsonare the standard Rust tools for serialization- command parsing should be handled by your own parser for REPL commands, not by
clap - terminal input can be handled with
stdin().read_line(...)
Use:
use std::collections::HashMap;Why:
- standard library type
- fast key lookup
- perfect fit for
SET,GET, andDELETE
Recommended first store shape:
HashMap<String, String>Do not start with mixed value types, TTL, binary persistence, or concurrency. Those are later-stage upgrades.
Use:
serdeserde_json
Why:
- standard and well-supported in Rust
- can serialize a
HashMap<String, String>directly - makes saved data human-readable
- easy to debug when things go wrong
Use two layers:
std::iofor interactive command input- an internal parser for commands like
SET name gokul
Important distinction:
clapis useful for program startup flags like--file data.jsonclapis not the right tool for parsing commands typed inside your own REPL loop
For this project, clap is optional, not required.
To keep the project clean and finishable, version 1 should support only these commands:
SET <key> <value>GET <key>DELETE <key>SAVE <file>LOAD <file>BACKUP <file>RESTORE <file>LISTHELPEXIT
- inserts a new key if it does not exist
- overwrites the old value if the key already exists
- returns a success message such as
OK
- prints the stored value if the key exists
- prints a clear "not found" message if it does not exist
- removes the key if it exists
- prints whether deletion happened or the key was missing
- writes the current in-memory data to disk
- should serialize the full store, not partial entries
- should print success or a detailed error
- reads store data from disk
- should replace the current in-memory data in version 1
- should reject invalid file content cleanly
- prints all keys, or all key-value pairs
- useful for debugging and demoing the store
- prints available commands and examples
- closes the program cleanly
The most correct structure is a small modular CLI app, not a single large main.rs.
redis-lite/
├── Cargo.toml
├── README.md
└── src/
├── main.rs
├── command.rs
├── parser.rs
├── store.rs
├── persistence.rs
└── error.rs
Owns the application loop.
Responsibilities:
- print welcome text
- read user input from terminal
- call the parser
- execute parsed commands against the store
- print results and errors
Defines the command model.
Example direction:
pub enum Command {
Set { key: String, value: String },
Get { key: String },
Delete { key: String },
Save { file: String },
Load { file: String },
List,
Help,
Exit,
}Why this is correct:
- commands become typed data instead of raw strings
- parsing and execution stay separate
- easier to test than mixing parsing logic with I/O
Converts raw terminal input into Command.
Responsibilities:
- trim whitespace
- split the command word from the rest
- validate argument counts
- return structured errors for invalid input
Key design rule:
For SET, treat everything after the key as the value so values can contain spaces.
Example:
SET theme dark blue
Should become:
- key:
theme - value:
dark blue
This is one of the most important parser decisions in the whole project.
Owns the in-memory database.
Recommended shape:
pub struct Store {
data: HashMap<String, String>,
}Responsibilities:
- set key-value pairs
- get values
- delete values
- list entries
- replace whole state during load
Best practice:
Keep file I/O out of store.rs. The store should manage data, not files.
Owns save/load logic.
Responsibilities:
- serialize store data to JSON
- write JSON to disk
- read JSON from disk
- deserialize JSON back into a store-compatible structure
Keep persistence separate from terminal printing.
Defines application errors.
Recommended error categories:
- invalid command format
- unknown command
- missing argument
- I/O failure
- serialization failure
- deserialization failure
- file not found
For version 1, use only:
HashMap<String, String>This is the correct choice because it keeps the project focused on database behavior instead of type-system expansion.
You could imagine values like numbers, booleans, lists, or JSON objects, but adding those now creates extra complexity in:
- parsing
- serialization rules
- command syntax
- printing behavior
- tests
For a learning project, String -> String is the right first milestone.
Example saved file:
{
"theme": "dark",
"timeout": "30",
"session": "abc123"
}Why JSON is the best first persistence format:
- readable by humans
- easy to inspect in an editor
- easy to debug
- supported directly by
serde_json
At minimum:
- serialize the full map
- write to the requested file path
- return success or failure
Better practice:
- write to a temporary file first
- rename the temp file to the final file
That reduces corruption risk if the program crashes during save.
For version 1, use replace semantics.
That means:
- file contents become the new full state
- current in-memory data is replaced
This is better than merge semantics for a beginner project because behavior stays predictable.
This part matters more than it seems.
Do not directly mix these concerns together:
- reading terminal input
- parsing command syntax
- executing store actions
- formatting output
Keep the flow like this:
stdin line -> parser -> Command enum -> executor/store -> terminal output
This separation is what makes the code maintainable.
- commands should be case-insensitive or clearly documented as case-sensitive
- extra leading and trailing spaces should be ignored
- unknown commands should show help guidance
- invalid argument counts should produce exact feedback
Example:
SET username
Should not fail vaguely. It should say something like:
error: SET requires a key and a value
Keep terminal behavior simple and consistent.
Use standard output for:
- successful command results
- values returned by
GET - help text
- normal status messages
Use standard error for:
- invalid commands
- failed file operations
- parse errors
- load failures
This is a small detail, but it is good CLI discipline and matches how serious tools behave.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"[dependencies]
thiserror = "2"
clap = { version = "4", features = ["derive"] }Use thiserror if you want cleaner custom error types.
Use clap only if you want startup arguments like:
redis-lite --file data.json
redis-lite --load startup.json
For the core REPL itself, your own parser is still the right design.
This is the cleanest implementation path.
Goal:
- define the
Commandenum - define command variants and required data
Done correctly when:
- every supported command has a typed representation
- no command execution logic is mixed in yet
Goal:
- convert raw strings into
Command
Done correctly when:
- valid commands parse successfully
- invalid commands return precise errors
SEThandles values containing spaces
Goal:
- create a
Storetype aroundHashMap<String, String>
Done correctly when:
set,get,delete, andlistbehave independently of terminal code
Goal:
- save store data to JSON
- load store data from JSON
Done correctly when:
- saved files can be opened and inspected by hand
- loading malformed JSON returns a clean error
Goal:
- read commands from terminal continuously until
EXIT
Done correctly when:
- I/O, parsing, execution, and output remain separate
- the program never crashes on bad input
Goal:
- verify parser behavior
- verify store behavior
- verify persistence behavior
Done correctly when:
- command parsing is covered for success and failure cases
- save/load round-trip tests pass
If this project is done like a professional Rust project, most logic should be testable without launching the full app.
- parse
SET name gokul - parse
SET bio rust developer - parse
GET name - reject empty input
- reject unknown commands
- reject missing arguments
- insert a new key
- overwrite an existing key
- get an existing value
- delete an existing value
- delete a missing key
- save a populated store
- load a valid JSON file
- reject invalid JSON
- round-trip save then load and compare equality
Later, you can add CLI-level tests for full command execution, but that should come after the modules are already stable.
These are the main design mistakes that would make the project harder than necessary.
This makes parsing, storage, persistence, and output tightly coupled.
clap is for command-line arguments passed when the program starts, not for repeated interactive commands typed after startup.
Use owned String values. Borrowed references will create lifetime problems for no benefit here.
Persistence should be in its own module.
Do not begin with enums for integers, booleans, lists, and null. First finish the simpler string-based store.
Bad input should produce exact messages, not generic failure output.
This small project is useful for:
- storing app settings
- saving temporary session data
- caching simple values
- fast local lookup for small tools
It is also useful as a teaching project for:
- command-driven application design
- REPL architecture
- Rust ownership with collections
- serialization workflows
- clean module boundaries
If this project is being built for learning, the most correct version is:
- interactive terminal app
- custom
Commandenum - custom parser
Storewrapper aroundHashMap<String, String>- JSON save/load with
serdeandserde_json - clear separation between parser, storage, persistence, and output
- tests for parser, store, and persistence before any advanced features
That is the right architecture because it stays small, idiomatic, and extensible.
Once version 1 is complete, the natural next upgrades are:
- startup arguments with
clap - atomic save strategy with temp-file rename
- command history and better REPL UX
- typed values instead of only strings
- expiration or TTL support
- append-only log instead of snapshot saves
- TCP server mode
- concurrent access handling
The future TCP server version should reuse the same parser, store, and persistence design where possible.
This project should not start as a "mini Redis clone" in the full sense. That would push it toward networking, protocols, concurrency, persistence guarantees, and much more complexity than you need.
The correct first interpretation of "Redis-lite" is:
- local process only
- string keys and values
- command-driven interface
- snapshot persistence
- strong modular structure
If you build it in that order, you will learn the right Rust concepts without overcomplicating the project.