
Personal Ledger | Backend
Backend server for the Personal Ledger application. This repository contains a Rust gRPC daemon that interfaces with the database and serves data to clients.
Explore the docs »
·
Personal Ledger (Parent Repo)
·
Report Bug
·
Request Feature
·
Quickstart
- Build:
cargo build
- Run:
cargo run
- Tests:
cargo test
If you use the provided devcontainer, rebuild it first (Command Palette → "Dev Containers: Rebuild Container") and then open a terminal in the container to run the commands above.
Development
- Language: Rust
- Primary crates & tooling:
tonic
(gRPC),sqlx
(Postgres),tracing
,serde
,thiserror
. - Devcontainer:
.devcontainer/devcontainer.json
includes tools such asmdbook
,cargo-make
,cargo-watch
,cargo-audit
,clippy
,rustfmt
, andprotoc
. - Copilot instructions can be found in
.github/copilot-instructions.md
Documentation
Project docs live in the docs/
folder. They can be browsed at https://ianteda.github.io/personal-ledger-backend/ or local running the command:
-
Serve mdBook documentation (uses port
8001
in the devcontainer):mdbook serve --port 8001
Protobuf / gRPC
Protobuf files are stored under protos/
(submodule). The build script build.rs
compiles protos into Rust types — ensure protoc
is available (the devcontainer installs protobuf-compiler
).
Concirm gRPC reflections service with grpcurl -plaintext localhost:50051 list
Code Quality
- Formatting:
cargo fmt
/rustfmt
- Linting:
cargo clippy
- Coverage:
cargo tarpaulin
(available in the devcontainer)
Running the MCP / awesome-copilot server (optional)
If you want to run the awesome-copilot
MCP image from the awesome-copilot
project locally (for experimenting with Copilot customizations), you can run it with Docker inside the devcontainer:
docker run -i --rm ghcr.io/microsoft/mcp-dotnet-samples/awesome-copilot:latest
Do not commit any credential or secret files. If the MCP server requires configuration, prefer using a workspace-local .vscode/
configuration file and local secrets (user settings or environment variables).
Contributing
Please open issues or pull requests for bugs and feature requests. Follow the repository coding standards: add tests, run cargo fmt
, and run cargo clippy
before submitting.
License
See the repository LICENSE
file for licensing information.
Configuration
This document explains how the Personal Ledger backend loads configuration, what defaults it uses, how to provide a config file, and how to override values with environment variables.
Primary implementation: src/config.rs
(types LedgerConfig
and ServerConfig
, loader LedgerConfig::parse
).
Configuration sources and precedence
The loader merges three sources in increasing priority (later sources override earlier ones):
- Defaults (lowest priority)
- Optional configuration file at
config/ledger-backend.conf
- Environment variables prefixed with
LEDGER_BACKEND_
(highest priority)
This means environment variables override values in the config file, and the config file overrides built-in defaults.
Built-in defaults
The application provides sensible defaults when no configuration is provided:
-
server.address
—127.0.0.1
-
server.port
—50059
-
server.tls_enabled
—false
-
server.tls_cert_path
—null
(none) -
server.tls_key_path
—null
(none) -
server.log_level
—INFO
(default). Acceptable values:OFF
,ERROR
,WARN
,INFO
,DEBUG
,TRACE
.
The constants that control these defaults are defined in src/config.rs
as DEFAULT_SERVER_ADDRESS
, DEFAULT_SERVER_PORT
, and DEFAULT_TLS_ENABLED
.
Config file format (INI/CONF)
The loader expects INI by default and looks for config/ledger-backend.conf
. A minimal file looks like this:
[server]
# The IP address the gRPC server will bind to.
# Defaults to 127.0.0.1 when not provided.
address = "127.0.0.1"
# The port the gRPC server will listen on.
# Defaults to 50059 when not provided.
port = 50059
# The logging level for the application.
# Acceptable values: OFF, ERROR, WARN, INFO, DEBUG, TRACE
# Defaults to INFO when not provided.
# log_level = "INFO"
# Enable TLS for the gRPC server. Set to true to enable TLS and provide the
# certificate and key file paths below.
tls_enabled = false
# Optional paths to the TLS certificate and key files (PEM format).
# When `tls_enabled = true` both values should be provided.
# tls_cert_path = "/path/to/tls/cert.pem"
# tls_key_path = "/path/to/tls/key.pem"
The repository also contains a commented example config/ledger-backend.conf
(INI-like) with the same fields — this is provided as a reference and convenience for developers, but the loader expects .conf unless changed in code.
Environment variable overrides
Any configuration key may be overridden using environment variables using the LEDGER_BACKEND
prefix. The loader uses underscores as separators; example mappings:
server.address
→LEDGER_BACKEND_SERVER_ADDRESS
server.port
→LEDGER_BACKEND_SERVER_PORT
server.tls_enabled
→LEDGER_BACKEND_SERVER_TLS_ENABLED
server.tls_cert_path
→LEDGER_BACKEND_SERVER_TLS_CERT_PATH
server.tls_key_path
→LEDGER_BACKEND_SERVER_TLS_KEY_PATH
server.log_level
→LEDGER_BACKEND_SERVER_LOG_LEVEL
Examples:
export LEDGER_BACKEND_SERVER_ADDRESS=0.0.0.0
export LEDGER_BACKEND_SERVER_PORT=50059
export LEDGER_BACKEND_SERVER_TLS_ENABLED=true
export LEDGER_BACKEND_SERVER_TLS_CERT_PATH=/etc/personal-ledger/cert.pem
export LEDGER_BACKEND_SERVER_TLS_KEY_PATH=/etc/personal-ledger/key.pem
export LEDGER_BACKEND_SERVER_LOG_LEVEL=DEBUG
cargo run
You can also set env vars inline when running:
LEDGER_BACKEND_SERVER_ADDRESS=0.0.0.0 LEDGER_BACKEND_SERVER_PORT=50059 cargo run
Set log_level
via environment variable (example):
LEDGER_BACKEND_SERVER_LOG_LEVEL=DEBUG cargo run
Log level values
The server.log_level
configuration value is deserialized using a serde-friendly enum
(telemetry::LogLevel
) that accepts lowercase names thanks to #[serde(rename_all = "lowercase")]
.
This means you may provide values in lowercase or uppercase; the canonical serialized form is lowercase.
Accepted values (examples):
off
orOFF
error
orERROR
warn
orWARN
info
orINFO
debug
orDEBUG
trace
orTRACE
Examples
- INI / .conf (config/ledger-backend.conf)
[server]
log_level = "info"
- Environment variable (both lowercase/uppercase are accepted)
# both are accepted
LEDGER_BACKEND_SERVER_LOG_LEVEL=debug cargo run
LEDGER_BACKEND_SERVER_LOG_LEVEL=DEBUG cargo run
## Troubleshooting
- Error: `Config(Parsing(missing field `server`))` — This means the deserialized data did not contain the required `server` section. Add the `server:` section to your YAML file or set the required fields via environment variables.
- If the server fails to bind, ensure the `server.address:server.port` combination is valid and not already in use.
- If enabling TLS, make sure `tls_enabled` is `true` and both `tls_cert_path` and `tls_key_path` point to valid PEM files.
## Quick run examples
Run with defaults (no file, no env):
```bash
cargo run
Run with file and env overrides:
# File: config/ledger-backend.yaml exists with values
LEDGER_BACKEND_SERVER_PORT=60000 cargo run
Where to look in the code
src/config.rs
— loader, defaults, andLedgerConfig
/ServerConfig
typessrc/error.rs
— mapping configuration errors intoLedgerError
/tonic::Status
where relevant
Development Container (devcontainer
)
The Personal Ledger Backend project provides a pre-configured development container (devcontainer) to ensure a consistent and reproducible development environment.
What is a Devcontainer?
A devcontainer is a Docker-based environment defined by configuration files (i.e. .devcontainer/devcontainer.json
) that sets up the tools, dependencies, and settings needed for development. When you open the project in Visual Studio Code with the Dev Containers extension, VS Code will automatically build and connect to this container, giving you a ready-to-code workspace.
What's Included?
The devcontainer for this project is based on the official Rust image and includes:
- Rust toolchain (latest stable, with
clippy
,rustfmt
) - Protobuf compiler (
protoc
andlibprotobuf-dev
) - Common Rust utilities:
mdbook
,cargo-make
,cargo-watch
,cargo-audit
- Docker CLI (for running containers inside the devcontainer)
- Git and GitHub CLI
- grpcurl (for testing gRPC endpoints)
- VS Code extensions for Rust, Docker, Markdown, SQL, YAML, and Protocol Buffers
- Port forwarding for mdBook documentation server (port 8001)
- Pre-configured environment for code formatting, linting, and testing
How to Use the Devcontainer
-
Open the Project in VS Code
Open the root of the repository in Visual Studio Code. -
Rebuild the Devcontainer (First Time or After Changes)
Open the Command Palette (Ctrl+Shift+P
orCmd+Shift+P
on Mac) and run:Dev Container: Build and Run
-
Wait for Setup
VS Code will build the container image, install all dependencies, and set up the environment. This may take a few minutes the first time. -
Start Developing
- Use the integrated terminal for running commands (
cargo build
,cargo run
,cargo test
, etc.). - All tools and extensions are pre-installed and ready to use.
- The container includes everything needed for Rust, gRPC, and documentation workflows.
- Serving Documentation
To serve the project documentation locally (mdBook), run:
Error Handling
This document describes the error handling system implemented in the Personal Ledger backend, which provides standardized error management across the entire application.
Overview
The error handling system is built around a custom LedgerError
enum that centralises all error types used throughout the application. This approach ensures consistent error handling, proper error propagation, and appropriate HTTP/gRPC status code mapping.
Core Components
LedgerError Enum
The LedgerError
enum is the central error type for the application, defined in src/error.rs
. It uses the thiserror
crate for ergonomic error handling and automatic error message formatting.
LedgerResult Type Alias
For convenience, a LedgerResult<T>
type alias is provided:
#![allow(unused)] fn main() { pub type LedgerResult<T> = std::result::Result<T, LedgerError>; }
Error Categories
Grpc Errors
- Purpose: Handle gRPC communication and Tonic framework errors
- Source:
tonic::Status
- Usage: Automatically converted from Tonic status codes
- gRPC Mapping: Passed through unchanged
TonicTransport Errors
- Purpose: Handle transport layer errors (connection, binding, etc.)
- Source:
tonic::transport::Error
- Usage: Automatically converted from transport operations
- gRPC Mapping: Maps to
INTERNAL
status
Configuration Errors
- Purpose: Handle configuration file loading and parsing issues
- Source: Custom string messages
- Usage:
LedgerError::config("message")
orLedgerError::Config("message".to_string())
- gRPC Mapping: Maps to
INTERNAL
status
I/O Errors
- Purpose: Handle file system and I/O operation failures
- Source:
std::io::Error
- Usage: Automatically converted from I/O operations
- gRPC Mapping: Maps to
INTERNAL
status
Validation Errors
- Purpose: Handle data validation failures
- Source: Custom string messages
- Usage:
LedgerError::validation("message")
orLedgerError::Validation("message".to_string())
- gRPC Mapping: Maps to
INVALID_ARGUMENT
status
Authentication Errors
- Purpose: Handle JWT and authentication-related failures
- Source: Custom string messages
- Usage:
LedgerError::authentication("message")
orLedgerError::Authentication("message".to_string())
- gRPC Mapping: Maps to
UNAUTHENTICATED
status
Internal Errors
- Purpose: Handle unexpected server errors and edge cases
- Source: Custom string messages
- Usage:
LedgerError::internal("message")
orLedgerError::Internal("message".to_string())
- gRPC Mapping: Maps to
INTERNAL
status
Usage Examples
Basic Error Creation
#![allow(unused)] fn main() { use personal_ledger_backend::error::{LedgerError, LedgerResult}; // Create errors using constructor methods let config_err = LedgerError::config("Configuration file not found"); let validation_err = LedgerError::validation("Invalid email format"); let auth_err = LedgerError::authentication("Token expired"); // Create errors directly let internal_err = LedgerError::Internal("Unexpected database state".to_string()); }
Error Propagation
#![allow(unused)] fn main() { use personal_ledger_backend::error::LedgerResult; async fn process_user_data(data: UserData) -> LedgerResult<ProcessedData> { // Validation error if data.email.is_empty() { return Err(LedgerError::validation("Email is required")); } // I/O operation with automatic error conversion let file_content = std::fs::read_to_string("config.json")?; // Configuration parsing let config: Config = serde_json::from_str(&file_content) .map_err(|_| LedgerError::config("Invalid configuration format"))?; Ok(processed_data) } }
gRPC Status Code Mapping
The LedgerError
enum implements From<LedgerError> for tonic::Status
, ensuring appropriate HTTP status codes are returned to clients:
Error Variant | gRPC Status Code | HTTP Status |
---|---|---|
Grpc | Original status | Varies |
TonicTransport | INTERNAL | 500 |
Config | INTERNAL | 500 |
Io | INTERNAL | 500 |
Validation | INVALID_ARGUMENT | 400 |
Authentication | UNAUTHENTICATED | 401 |
Internal | INTERNAL | 500 |
Best Practices
1. Use Appropriate Error Types
Choose the most specific error variant for your use case:
#![allow(unused)] fn main() { // ✅ Good: Specific validation error if !is_valid_email(email) { return Err(LedgerError::validation("Invalid email format")); } // ❌ Avoid: Generic internal error for validation if !is_valid_email(email) { return Err(LedgerError::internal("Email validation failed")); } }
2. Provide Descriptive Messages
Include context in error messages:
#![allow(unused)] fn main() { // ✅ Good: Descriptive message return Err(LedgerError::config(format!("Failed to load config from {}", path))); // ❌ Avoid: Vague message return Err(LedgerError::config("Config error")); }
3. Use Constructor Methods
Prefer constructor methods for consistency:
#![allow(unused)] fn main() { // ✅ Good: Constructor method let err = LedgerError::validation("Field is required"); // ✅ Also good: Direct construction let err = LedgerError::Validation("Field is required".to_string()); }
4. Handle Errors at Appropriate Boundaries
Convert errors at service boundaries:
#![allow(unused)] fn main() { // In repository layer - use specific errors async fn find_user(&self, id: Uuid) -> LedgerResult<User> { sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id) .fetch_one(&self.pool) .await .map_err(|e| match e { sqlx::Error::RowNotFound => LedgerError::validation(format!("User {} not found", id)), _ => LedgerError::internal(format!("Database error: {}", e)), }) } }
Testing
The error module includes comprehensive unit tests:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn test_error_creation() { // Test constructor methods } #[test] fn test_error_to_status_conversion() { // Test gRPC status mapping } } }
Run tests with:
cargo test error
Custom Error Variants
Add new error variants as needed:
#![allow(unused)] fn main() { /// Business logic errors #[error("Business logic error: {0}")] BusinessLogic(String), }
Module Organization
The error module is organized as follows:
src/error.rs
: Main error definitions and implementationssrc/lib.rs
: Public exports (LedgerError
,LedgerResult
)docs/error.md
: This documentation
Dependencies
thiserror
: For ergonomic error definitions and formattingtonic
: For gRPC status code integration
Related Documentation
- gRPC Status Codes
- Rust Error Handling
- thiserror Documentation
/workspaces/personal-ledger-backend/docs/error.md