Refactor CLI to use structured command definitions #8

Open
opened 2026-04-14 09:22:03 +02:00 by jakob · 3 comments
Owner

The current CLI implementation relies on manual index-based argument parsing in main.rs, which is fragile and difficult to extend. We need to migrate this to a structured command pattern (using clap) to ensure consistent argument validation, automatic help generation, and a maintainable codebase.

Goals:

  • Replace manual std::env::args() parsing with a declarative CLI structure.
  • Standardize global flags across all commands.
  • Decouple CLI parsing from business logic execution.
  • Implement integration tests for each command path.
  • Rebuild app using TDD principles from the start and initially targeting rusty-petro-cli

Todo

Phase 1: Scaffolding & Infrastructure

  • Domain & Service Modeling: Decouple domain logic from interface implementations (CLI/Web).
    • Redesign Database schema using new clarity of functions accessing it
    • Schema Definition: Write the actual SQL CREATE TABLE statements based on blueprint.
    • Domain Type Mapping:
      • Define Core Entity Structs (Customer, Card, Transaction, Invoice, Batch).
      • Implement Domain Validation rules (e.g., non-negative amounts).
    • Service Interface Design:
      • Define Repository Traits (low-level DB access contracts for each entity).
      • Design the Service Layer API (high-level business operations orchestrating repositories).
  • Project Configuration: Define rusty-petro-cli as a new binary target in Cargo.toml.
  • CLI Framework: Implement the base clap structure with global flags (--env, --quiet, --format).
  • TDD Harness:
    • Set up an integration test suite (e.g., using assert_cmd and predicates) to test binary execution and output.
    • Create a Service Layer Unit Test Suite to enforce business rules, specifically the "Immutable Ledger" constraints (no transaction updates/deletes).
  • Core Logic Extraction: Move all domain types and services into a shared library (lib.rs) so both the CLI and Daemon can use it without duplication.

🧪 Phase 2: Iterative Command Implementation (TDD Cycle)

For each command group below, the workflow will be: Write Test \rightarrow Implement Parsing \rightarrow Implement Logic \rightarrow Verify.

  • DB Management: setup, reset, status, backup, restore.
  • Customer CRUD: create, read, update, delete, list.
  • Card Management: create, read, update, delete, list.
  • Batch Operations: update (recalculation), list.
  • Transaction Access: read, list.
  • Invoice Workflow: create, read, update, delete, credit, export, list.
  • Daemon Control: start, status, stop.

🎨 Phase 3: Refinement & Polishing

  • Output Formatting: Implement the formatters for raw, json, csv, and columns.
  • Environment Validation: Verify that --env correctly switches database contexts across all commands.
  • Edge Case Testing: Add tests for invalid inputs, permission errors (e.g., deleting non-draft invoices), and DB connection failures.

🏁 Phase 4: Finalization

  • Spec Audit: Cross-reference the final binary against the Gitea issue #8 specification.
  • Deprecation: Remove/deactivate the old manual argument parsing logic from the original entry point.
  • Documentation: Update any README or internal docs to reflect the new command structure.
The current CLI implementation relies on manual index-based argument parsing in main.rs, which is fragile and difficult to extend. We need to migrate this to a structured command pattern (using clap) to ensure consistent argument validation, automatic help generation, and a maintainable codebase. ## Goals: - Replace manual std::env::args() parsing with a declarative CLI structure. - Standardize global flags across all commands. - Decouple CLI parsing from business logic execution. - Implement integration tests for each command path. - Rebuild app using TDD principles from the start and initially targeting rusty-petro-cli ## Todo ### Phase 1: Scaffolding & Infrastructure - [ ] Domain & Service Modeling: Decouple domain logic from interface implementations (CLI/Web). - [x] **Redesign Database** schema using new clarity of functions accessing it - [x] **Schema Definition**: Write the actual SQL CREATE TABLE statements based on blueprint. - [ ] **Domain Type Mapping**: - Define Core Entity Structs (Customer, Card, Transaction, Invoice, Batch). - Implement Domain Validation rules (e.g., non-negative amounts). - [ ] **Service Interface Design**: - Define Repository Traits (low-level DB access contracts for each entity). - Design the Service Layer API (high-level business operations orchestrating repositories). - [ ] **Project Configuration**: Define rusty-petro-cli as a new binary target in Cargo.toml. - [ ] **CLI Framework**: Implement the base clap structure with global flags (--env, --quiet, --format). - [ ] **TDD Harness**: - Set up an integration test suite (e.g., using assert_cmd and predicates) to test binary execution and output. - Create a Service Layer Unit Test Suite to enforce business rules, specifically the "Immutable Ledger" constraints (no transaction updates/deletes). - [ ] **Core Logic Extraction**: Move all domain types and services into a shared library (lib.rs) so both the CLI and Daemon can use it without duplication. ### 🧪 Phase 2: Iterative Command Implementation (TDD Cycle) For each command group below, the workflow will be: Write Test $\rightarrow$ Implement Parsing $\rightarrow$ Implement Logic $\rightarrow$ Verify. - [ ] DB Management: setup, reset, status, backup, restore. - [ ] Customer CRUD: create, read, update, delete, list. - [ ] Card Management: create, read, update, delete, list. - [ ] Batch Operations: update (recalculation), list. - [ ] Transaction Access: read, list. - [ ] Invoice Workflow: create, read, update, delete, credit, export, list. - [ ] Daemon Control: start, status, stop. ### 🎨 Phase 3: Refinement & Polishing - [ ] Output Formatting: Implement the formatters for raw, json, csv, and columns. - [ ] Environment Validation: Verify that --env correctly switches database contexts across all commands. - [ ] Edge Case Testing: Add tests for invalid inputs, permission errors (e.g., deleting non-draft invoices), and DB connection failures. ### 🏁 Phase 4: Finalization - [ ] Spec Audit: Cross-reference the final binary against the Gitea issue #8 specification. - [ ] Deprecation: Remove/deactivate the old manual argument parsing logic from the original entry point. - [ ] Documentation: Update any README or internal docs to reflect the new command structure.
Author
Owner

3rd draft for cli structure:

rusty-petro-cli
├── batch
│   ├── update       # 1. Recalculate all values of batch using data from transactions table
│   │                # 2. Update batch values
│   └── list         # list all batches in DB
│
├── card
│   ├── create       # add card to DB
│   │                #   - require customer
│   │                #   - require status
│   │                #   - require description
│   │                #   - require pin
│   ├── read         # fetch card details
│   ├── update       # modify card
│   ├── delete       # delete card
│   │                #   only allowed on cards not referenced by any transactions 
│   └── list         # list all cards
│                    #   allow filtering by customer, status
│
├── customer
│   ├── create       # add customer
│   │                #   - require customer number (ID) and name
│   ├── read         # fetch customer details
│   ├── update       # modify customer
│   ├── delete       # delete customer
│   │                #   only allowed on customers not referenced by any transactions 
│   └── list         # list all customers
│
├── daemon
│   ├── start        # start the web server daemon
│   ├── status       # check status of daemon
│   │                #   - running?
│   │                #   - port?
│   │                #   - db access?
│   │                #   - accessible from internet?
│   │                #   - fqdn?
│   └── stop         # stop daemon
│
├── db
│   ├── setup        # create database and schema
│   ├── reset        # drop and recreate database
│   ├── status       # check if connection to db is ok
│   ├── backup       # create a backup of database
│   └── restore      # recreate database from backup
│
├── file
│   ├── import       # read CSV into DB:
│   │                #   1. create any missing customers
│   │                #   2. create any missing cards
│   │                #   3. create transactions
│   │                #   4. create any missing batches
│   │                #   5. verify batch values match calculated value
│   ├── list         # list all files stored in DB
│   └── export       # export file to specified format
│
├── invoice
│   ├── create       # add invoice to DB:
│   │                #   - require batch OR date range
│   │                #   - require "all" or specific customer
│   ├── read         # fetch invoice details
│   ├── update       # modify invoice
│   │                #   - only allowed on invoices with "draft" status
│   ├── delete       # delete invoice
│   │                #   - only allowed on invoice with the highest ID number
│   │                #   - only allowed if invoice status is "draft"
│   ├── credit       # create a credit invoice
│   │                #   - required if errors is needed to be fixed on a sent invoice
│   ├── export       # write HTML files to disk
│   └── list         # list all invoices
│                    #   - allow filtering list by customer, date
│
└── transaction      # CRUD not needed via cli, transactions will only be added via "file import"
    ├── read         # fetch transaction details
    │                #   - CREATE not needed, they will only be created via "file import" function
    │                #   - UPDATE not needed, transactions are immutable
    │                #   - DELETE not needed, transactions are immutable
    └── list         # list transactions (with filtering)

Global flags:

--env=[dev,test]                  # production assumed
--quiet                           # for scripts only caring about exit codes
--help                            # display basic usage information
--format=[raw,json,csv,columns]   # columns assumed
3rd draft for cli structure: ``` rusty-petro-cli ├── batch │ ├── update # 1. Recalculate all values of batch using data from transactions table │ │ # 2. Update batch values │ └── list # list all batches in DB │ ├── card │ ├── create # add card to DB │ │ # - require customer │ │ # - require status │ │ # - require description │ │ # - require pin │ ├── read # fetch card details │ ├── update # modify card │ ├── delete # delete card │ │ # only allowed on cards not referenced by any transactions │ └── list # list all cards │ # allow filtering by customer, status │ ├── customer │ ├── create # add customer │ │ # - require customer number (ID) and name │ ├── read # fetch customer details │ ├── update # modify customer │ ├── delete # delete customer │ │ # only allowed on customers not referenced by any transactions │ └── list # list all customers │ ├── daemon │ ├── start # start the web server daemon │ ├── status # check status of daemon │ │ # - running? │ │ # - port? │ │ # - db access? │ │ # - accessible from internet? │ │ # - fqdn? │ └── stop # stop daemon │ ├── db │ ├── setup # create database and schema │ ├── reset # drop and recreate database │ ├── status # check if connection to db is ok │ ├── backup # create a backup of database │ └── restore # recreate database from backup │ ├── file │ ├── import # read CSV into DB: │ │ # 1. create any missing customers │ │ # 2. create any missing cards │ │ # 3. create transactions │ │ # 4. create any missing batches │ │ # 5. verify batch values match calculated value │ ├── list # list all files stored in DB │ └── export # export file to specified format │ ├── invoice │ ├── create # add invoice to DB: │ │ # - require batch OR date range │ │ # - require "all" or specific customer │ ├── read # fetch invoice details │ ├── update # modify invoice │ │ # - only allowed on invoices with "draft" status │ ├── delete # delete invoice │ │ # - only allowed on invoice with the highest ID number │ │ # - only allowed if invoice status is "draft" │ ├── credit # create a credit invoice │ │ # - required if errors is needed to be fixed on a sent invoice │ ├── export # write HTML files to disk │ └── list # list all invoices │ # - allow filtering list by customer, date │ └── transaction # CRUD not needed via cli, transactions will only be added via "file import" ├── read # fetch transaction details │ # - CREATE not needed, they will only be created via "file import" function │ # - UPDATE not needed, transactions are immutable │ # - DELETE not needed, transactions are immutable └── list # list transactions (with filtering) ``` Global flags: ``` --env=[dev,test] # production assumed --quiet # for scripts only caring about exit codes --help # display basic usage information --format=[raw,json,csv,columns] # columns assumed ```
Author
Owner

@jakob:

Perfect. A question arises in my mind: Will the fact that we eventually are going to set up a web server hosting a SPA with expanded functionaly affect this plan?

@opencode:

Yes, it does affect the plan, primarily in how you architect the shared library (lib.rs).
If you don't decouple correctly now, you risk implementing "CLI-specific" logic that won't translate to a web environment. To future-proof for the SPA/Web Server, we should refine the architecture as follows:

  1. The "Service Layer" Pattern
    Instead of just moving code to lib.rs, we must implement a strict Service Layer.
  • CLI \rightarrow Service \rightarrow DB
  • Web Server \rightarrow Service \rightarrow DB
    The CLI and Web Server should be "thin" shells that only handle input/output. All validation, business rules (e.g., "only delete draft invoices"), and database orchestration must live in the Service Layer. This ensures that a rule enforced by the CLI is identical to the one enforced by the SPA.
  1. Direct DB Access vs. API
    You have two architectural paths for the CLI:
  • Direct Access (Current Plan): The CLI talks directly to the database. This is fast and great for admin tasks, but requires the CLI to be run on the same machine/network as the DB.
  • API Client: The CLI becomes a client that sends requests to the rusty-petro-daemon via HTTP/gRPC.
    My recommendation: Start with Direct Access, but wrap all database calls in traits or service objects. This way, if you decide later that the CLI should talk to the API instead of the DB, you only change the implementation of the service layer, not the CLI command structure itself.
  1. Data Transfer Objects (DTOs)
    Since a web server will likely use JSON, we should ensure our shared library uses types that are easily serializable/deserializable (e.g., using serde). This makes the transition to an API seamless because the "Language" the Service Layer speaks is already compatible with the web.
    Summary of changes to the Plan:
    I would add a specific step in Phase 1 called "Domain & Service Modeling", where we define the boundaries between the CLI/Daemon and the core business logic before writing any actual commands.
@jakob: > Perfect. A question arises in my mind: Will the fact that we eventually are going to set up a web server hosting a SPA with expanded functionaly affect this plan? @opencode: > Yes, it does affect the plan, primarily in how you architect the shared library (lib.rs). > If you don't decouple correctly now, you risk implementing "CLI-specific" logic that won't translate to a web environment. To future-proof for the SPA/Web Server, we should refine the architecture as follows: > 1. The "Service Layer" Pattern > Instead of just moving code to lib.rs, we must implement a strict Service Layer. > * CLI $\rightarrow$ Service $\rightarrow$ DB > * Web Server $\rightarrow$ Service $\rightarrow$ DB > The CLI and Web Server should be "thin" shells that only handle input/output. All validation, business rules (e.g., "only delete draft invoices"), and database orchestration must live in the Service Layer. This ensures that a rule enforced by the CLI is identical to the one enforced by the SPA. > 2. Direct DB Access vs. API > You have two architectural paths for the CLI: > * Direct Access (Current Plan): The CLI talks directly to the database. This is fast and great for admin tasks, but requires the CLI to be run on the same machine/network as the DB. > * API Client: The CLI becomes a client that sends requests to the rusty-petro-daemon via HTTP/gRPC. > My recommendation: Start with Direct Access, but wrap all database calls in traits or service objects. This way, if you decide later that the CLI should talk to the API instead of the DB, you only change the implementation of the service layer, not the CLI command structure itself. > 3. Data Transfer Objects (DTOs) > Since a web server will likely use JSON, we should ensure our shared library uses types that are easily serializable/deserializable (e.g., using serde). This makes the transition to an API seamless because the "Language" the Service Layer speaks is already compatible with the web. > Summary of changes to the Plan: > I would add a specific step in Phase 1 called "Domain & Service Modeling", where we define the boundaries between the CLI/Daemon and the core business logic before writing any actual commands.
Author
Owner

@jakob:

Expand a bit on "Domain & Service Modeling"

@opencode

Domain & Service Modeling is the process of defining the "brain" of your application separately from its "limbs" (the CLI, the Web Server, etc.).
Instead of writing a function that reads a CLI argument and then runs a SQL query, you build a layered architecture. Here is how it breaks down for rusty-petroleum:

  1. The Domain Layer (The "What")
    This layer contains the pure business logic and data structures. It has zero knowledge of databases or CLI flags.
  • Entities: Structs like Invoice, Card, and Customer.
  • Domain Rules: Logic that is always true regardless of how it's triggered.
    • Example: A function invoice.can_be_deleted() that returns true only if the status is "Draft".
  • Value Objects: Types like Money or CardPin to ensure type safety (preventing you from accidentally adding a Customer ID to an Invoice Amount).
  1. The Repository Layer (The "Where")
    This layer abstracts the data storage. The rest of the app doesn't know if you are using SQLite, PostgreSQL, or a JSON file.
  • Interface: You define traits like CustomerRepository with methods like find_by_id() or save().
  • Implementation: A concrete struct (e.g., SqliteCustomerRepository) that implements those traits using actual SQL.
  1. The Service Layer (The "How")
    This is the orchestrator. It coordinates between the Domain and the Repository to perform a specific use case.
  • Example: InvoiceService::create_invoice
    1. Calls CustomerRepository to verify the customer exists.
    2. Uses the Domain Layer to calculate totals and validate the date range.
    3. Calls InvoiceRepository to save the result.
    4. Returns a Result that the CLI or Web Server can then translate into a success message or an error code.
  1. The Interface Layer (The "Entry Point")
    This is where your new rusty-petro-cli and future Web Server live. Their only jobs are:
  • Input: Parsing a CLI flag (--customer 123) or an HTTP request (POST /invoices).
  • Execution: Calling the appropriate function in the Service Layer.
  • Output: Formatting the result as a table for the console or JSON for the browser.

Why this makes TDD easier:
With this model, you can write three levels of tests without ever needing a real database or running a binary:

  1. Unit Tests: Test Domain Rules (e.g., "Does can_be_deleted return false for 'Sent' invoices?"). (Fastest)
  2. Service Tests: Use a "Mock" repository (a fake in-memory DB) to test the orchestration logic. (Very Fast)
  3. Integration Tests: Run the actual rusty-petro-cli against a real test database. (Slower, but essential)
    Impact on the SPA:
    When you eventually build the Web Server, you won't "port" your CLI logic to the web. You will simply create a new Interface Layer (the API) that calls the exact same Service Layer already tested and proven by the CLI.
@jakob: > Expand a bit on "Domain & Service Modeling" @opencode > Domain & Service Modeling is the process of defining the "brain" of your application separately from its "limbs" (the CLI, the Web Server, etc.). > Instead of writing a function that reads a CLI argument and then runs a SQL query, you build a layered architecture. Here is how it breaks down for rusty-petroleum: > 1. The Domain Layer (The "What") > This layer contains the pure business logic and data structures. It has zero knowledge of databases or CLI flags. > * Entities: Structs like Invoice, Card, and Customer. > * Domain Rules: Logic that is always true regardless of how it's triggered. > * Example: A function invoice.can_be_deleted() that returns true only if the status is "Draft". > * Value Objects: Types like Money or CardPin to ensure type safety (preventing you from accidentally adding a Customer ID to an Invoice Amount). > 2. The Repository Layer (The "Where") > This layer abstracts the data storage. The rest of the app doesn't know if you are using SQLite, PostgreSQL, or a JSON file. > * Interface: You define traits like CustomerRepository with methods like find_by_id() or save(). > * Implementation: A concrete struct (e.g., SqliteCustomerRepository) that implements those traits using actual SQL. > 3. The Service Layer (The "How") > This is the orchestrator. It coordinates between the Domain and the Repository to perform a specific use case. > * Example: InvoiceService::create_invoice > 1. Calls CustomerRepository to verify the customer exists. > 2. Uses the Domain Layer to calculate totals and validate the date range. > 3. Calls InvoiceRepository to save the result. > 4. Returns a Result that the CLI or Web Server can then translate into a success message or an error code. > 4. The Interface Layer (The "Entry Point") > This is where your new rusty-petro-cli and future Web Server live. Their only jobs are: > * Input: Parsing a CLI flag (--customer 123) or an HTTP request (POST /invoices). > * Execution: Calling the appropriate function in the Service Layer. > * Output: Formatting the result as a table for the console or JSON for the browser. > --- > Why this makes TDD easier: > With this model, you can write three levels of tests without ever needing a real database or running a binary: > 1. Unit Tests: Test Domain Rules (e.g., "Does can_be_deleted return false for 'Sent' invoices?"). (Fastest) > 2. Service Tests: Use a "Mock" repository (a fake in-memory DB) to test the orchestration logic. (Very Fast) > 3. Integration Tests: Run the actual rusty-petro-cli against a real test database. (Slower, but essential) > Impact on the SPA: > When you eventually build the Web Server, you won't "port" your CLI logic to the web. You will simply create a new Interface Layer (the API) that calls the exact same Service Layer already tested and proven by the CLI.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jakob/rusty-petroleum#8