License: GPL Issues



Logo

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 as mdbook, cargo-make, cargo-watch, cargo-audit, clippy, rustfmt, and protoc.
  • 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):

  1. Defaults (lowest priority)
  2. Optional configuration file at config/ledger-backend.conf
  3. 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.address127.0.0.1

  • server.port50059

  • server.tls_enabledfalse

  • server.tls_cert_pathnull (none)

  • server.tls_key_pathnull (none)

  • server.log_levelINFO (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.addressLEDGER_BACKEND_SERVER_ADDRESS
  • server.portLEDGER_BACKEND_SERVER_PORT
  • server.tls_enabledLEDGER_BACKEND_SERVER_TLS_ENABLED
  • server.tls_cert_pathLEDGER_BACKEND_SERVER_TLS_CERT_PATH
  • server.tls_key_pathLEDGER_BACKEND_SERVER_TLS_KEY_PATH
  • server.log_levelLEDGER_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 or OFF
  • error or ERROR
  • warn or WARN
  • info or INFO
  • debug or DEBUG
  • trace or TRACE

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, and LedgerConfig/ServerConfig types
  • src/error.rs — mapping configuration errors into LedgerError / 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 and libprotobuf-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

  1. Open the Project in VS Code
    Open the root of the repository in Visual Studio Code.

  2. Rebuild the Devcontainer (First Time or After Changes)
    Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P on Mac) and run: Dev Container: Build and Run

  3. 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.

  4. 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.
  1. 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") or LedgerError::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") or LedgerError::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") or LedgerError::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") or LedgerError::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 VariantgRPC Status CodeHTTP Status
GrpcOriginal statusVaries
TonicTransportINTERNAL500
ConfigINTERNAL500
IoINTERNAL500
ValidationINVALID_ARGUMENT400
AuthenticationUNAUTHENTICATED401
InternalINTERNAL500

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 implementations
  • src/lib.rs: Public exports (LedgerError, LedgerResult)
  • docs/error.md: This documentation

Dependencies

  • thiserror: For ergonomic error definitions and formatting
  • tonic: For gRPC status code integration

Contributors