This file provides context for AI agents, LLMs, and developers working on this codebase.
quickbooks-cli (binary: qb) is a Rust CLI for the QuickBooks Online Accounting API v3. It covers 36 entity types, 25+ financial reports, OAuth2 PKCE authentication, SQLite response caching, and rate limiting.
Reference implementation: This project mirrors the architecture of osodevops/xero-cli — the team's Xero CLI.
cargo build # Build
cargo test # Run all tests (74 tests)
cargo clippy -- -D warnings # Lint (must pass with zero warnings)
cargo fmt --check # Format check
cargo run -- --help # Run CLI
cargo run -- customers list --help # Specific command helpThe project compiles with zero warnings under clippy -D warnings.
src/
├── main.rs # Entry point (~50 lines): Cli::parse(), tracing init, GlobalArgs, dispatch()
├── lib.rs # pub mod declarations + VERSION const (with embedded git hash)
├── error.rs # QBError enum (thiserror + miette Diagnostic), exit codes 0-10
│
├── cli/ # CLI layer — one file per entity
│ ├── mod.rs # Cli struct (Parser), GlobalArgs, Commands enum (43 variants), dispatch()
│ ├── common.rs # build_client(): config → auth → rate limiter → QBClient → CachedClient
│ ├── auth.rs # Login, Status, Refresh, Logout subcommands
│ ├── reports.rs # qb reports run <type> with 25+ report types
│ ├── query.rs # qb query run "<SQL>" with auto-pagination
│ ├── cache.rs # qb cache clear/stats
│ ├── config.rs # qb config init/show/set/path
│ ├── completions.rs # Shell completions (bash/zsh/fish) + man pages
│ └── {entity}.rs # 36 entity files (customers.rs, invoices.rs, etc.)
│
├── api/ # API client layer
│ ├── client.rs # QBClient: reqwest wrapper with retry, rate limit, bearer token,
│ │ # ?minorversion=75, prod/sandbox URLs, Fault error parsing,
│ │ # intuit_tid logging, SyncToken stale object detection
│ ├── pagination.rs # STARTPOSITION-based pagination (QBO-specific, 1-indexed)
│ └── endpoints/ # One file per entity + shared helpers
│ ├── common.rs # extract_entity(), extract_query_entities(), build_query()
│ ├── reports.rs # run_report(), REPORT_TYPES constant, resolve_report_name()
│ └── {entity}.rs # 36 entity endpoint files with Filters struct + CRUD functions
│
├── auth/ # Authentication layer
│ ├── mod.rs # TokenSet struct, Environment enum, ensure_authenticated()
│ ├── pkce.rs # OAuth2 PKCE browser flow + manual flow for headless servers
│ ├── refresh.rs # Token refresh with rotation handling + fs2 file lock for concurrency
│ └── token_store.rs # OS keyring storage (Apple/Windows/Linux native) with file fallback
│
├── cache/ # SQLite caching layer
│ ├── mod.rs # CachedClient middleware: caches GETs, invalidates on writes
│ └── store.rs # CacheStore: SQLite via rusqlite (bundled), TTL eviction, stats
│
├── config/ # Configuration
│ ├── mod.rs # AppConfig::load(), save(), default paths
│ ├── file.rs # ConfigFile TOML schema (5 sections, all with defaults)
│ └── profiles.rs # Profile struct (realm_id, environment, company_name)
│
├── models/ # Serde data models — one file per entity
│ ├── common.rs # Shared types: Ref, MetaData, Address, PhoneNumber, EmailAddress,
│ │ # WebAddress, Line, LinkedTxn, QueryResponse
│ ├── report.rs # Report model + flatten_report() + flatten_general_ledger()
│ └── {entity}.rs # 36 entity model files (all with #[serde(flatten)] extra field)
│
├── output/ # Output formatting
│ ├── mod.rs # OutputFormat enum (Table/Json/Csv/Yaml), Tabular trait, render()
│ ├── table.rs # comfy-table rendering
│ ├── json.rs # JSON pretty/compact
│ ├── csv.rs # CSV via csv crate
│ └── yaml.rs # YAML via serde_yaml
│
└── rate_limit/ # Rate limiting
├── limiter.rs # Sliding window (500/min) + Semaphore (10 concurrent)
├── backoff.rs # Exponential backoff with jitter (1s initial, 60s max, 3 retries)
└── budget.rs # Per-minute budget tracking with auto-reset
Each entity has exactly 3 files:
-
Model (
src/models/{entity}.rs):#[derive(Debug, Clone, Serialize, Deserialize)]struct- All fields are
Option<T>with#[serde(rename = "QBOFieldName")] - Uses
rust_decimal::Decimalfor monetary amounts - Last field:
#[serde(flatten)] pub extra: HashMap<String, serde_json::Value>
-
Endpoint (
src/api/endpoints/{entity}.rs):{Entity}Filtersstruct withwhere_clause,order_by,max_results,start_position- Functions:
list(),list_all(),get(),create(),update(),delete() - Uses
build_query()for QBO SQL andextract_entity()/extract_query_entities()for response parsing - List entities: soft delete (POST with
Active=false,sparse=true) - Transaction entities: hard delete (POST to
{entity}?operation=delete)
-
CLI (
src/cli/{entity}.rs):{Entity}Commandsenum withList,Get,Create,Update,Deletesubcommandsimpl Tabular for {Entity}withheaders()androw()for table outputexecute()async function: builds client, dispatches to endpoint, renders output- List uses
--whereand--order-byflags, checksglobal.all_pages - Create supports
--fileand inline flags with--dry-run - Update auto-fetches SyncToken before sending
- Delete requires
--confirm
- Create
src/models/{entity}.rswith serde struct - Create
src/api/endpoints/{entity}.rswith CRUD functions - Create
src/cli/{entity}.rswith Commands enum + Tabular impl + execute() - Add
pub mod {entity};tosrc/models/mod.rs,src/api/endpoints/mod.rs,src/cli/mod.rs - Add
Commandsvariant + dispatch arm insrc/cli/mod.rs
- Single entity read (
GET /customer/{id}):{"Customer": {...}} - Query/list (
GET /query?query=SELECT * FROM Customer):{"QueryResponse": {"Customer": [...], "startPosition": 1, "maxResults": 100}} - Create/Update (POST):
{"Customer": {...}}(returns entity) - Delete (POST
?operation=delete): minimal response - Errors:
{"Fault": {"Error": [{"Message": "...", "Detail": "...", "code": "..."}], "type": "ValidationFault"}} - Reports (
GET /reports/ProfitAndLoss):{"Header": {...}, "Columns": {...}, "Rows": {"Row": [...]}}
- SyncToken: Required for every update/delete. Changes on each mutation. Auto-fetched by the CLI.
- Token Rotation: QBO refresh tokens change on every use. Must persist new token immediately. Protected by
fs2file lock. - Pagination: Uses
STARTPOSITION(1-indexed) andMAXRESULTSin the query string, not page-based. - Minor Version:
?minorversion=75appended to all requests. - Base URLs: Production
quickbooks.api.intuit.com, Sandboxsandbox-quickbooks.api.intuit.com - Realm ID: Company identifier, part of the URL path:
/v3/company/{realmId}/
| Setting | Default | Notes |
|---|---|---|
| Output format | table |
Auto-switches to JSON when piped |
| Page size | 100 |
QBO max is 1000 |
| Minor version | 75 |
Current QBO API version |
| Cache list TTL | 300s |
5 minutes |
| Cache get TTL | 900s |
15 minutes |
| Requests/min | 500 |
QBO limit |
| Max concurrent | 10 |
QBO limit |
| Retry max | 3 |
With exponential backoff |
| Callback port | 8844 |
For OAuth PKCE flow |
All errors use thiserror + miette::Diagnostic with:
- Human-readable messages
- Error codes (
qb::auth,qb::api, etc.) - Help text with actionable suggestions
- Structured exit codes (0-10) for scripting
tokio— async runtimereqwest(rustls-tls) — HTTP client, no OpenSSLclap4 (derive) — CLI parsing with env var supportserde/serde_json/toml/csv/serde_yaml— serializationoauth2— OAuth2 PKCE flowkeyring— OS-native credential storagerusqlite(bundled) — SQLite cache, no system dependencycomfy-table— table outputthiserror+miette— error handling with diagnosticsrust_decimal— financial arithmeticfs2— cross-platform file locks (token refresh safety)
- 87 unit tests covering: error types, config parsing, output formats, model deserialization, rate limiting, backoff, budget tracking, cache operations, query parsing, report flattening, URL construction, fault parsing
- Tests use
tempfilefor isolated cache/token tests - Async tests use
#[tokio::test] - No integration tests against live QBO API yet (use wiremock for future tests)