Add invoices, quotes, payments, products, and expenses
CI / test (push) Canceled after 0s
CI / Build on the declared MSRV (push) Canceled after 0s
CI / Latest stable (advisory) (push) Canceled after 0s

Five more resources following clients.rs's template exactly: the
same list/get/create/update/delete shape, including the
fetch-merge-write pattern for update. quotes.rs is otherwise
identical to invoices.rs since Quote is a type alias for Invoice in
the SDK (same table, distinguished by entity_type upstream).

Verified against a live local server: full create/list/get/update/
delete round trip for all six resources, confirming update preserves
fields the caller didn't mention.

Also adds CHANGELOG.md (Keep a Changelog format, matching the SDK's)
and updates README/CLAUDE.md's resource counts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 15:34:46 -04:00
co-authored by Claude Sonnet 5
parent 2292239fd7
commit 1fbd7a7ce5
12 changed files with 772 additions and 11 deletions
+41
View File
@@ -0,0 +1,41 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Pre-1.0, per `GOALS.md`, a minor release can still break flags or output —
this file is where that gets written down instead of discovered at the
command line.
## [Unreleased]
### Added
- Six resources, each with `list` (with `--all` for every page, `--status`,
and `--param key=value` for anything `ListParams` doesn't have a typed
setter for), `get`, `create`, `update`, and `delete`: `clients`,
`invoices`, `quotes`, `payments`, `products`, `expenses`. `quotes` is
`invoices` under a different endpoint — Invoice Ninja stores both in the
same table, distinguished by `entity_type`, so `Quote` is a type alias for
`Invoice` in the SDK and `commands/quotes.rs` is otherwise identical to
`commands/invoices.rs`.
- `update` fetches the current record, shallow-merges the caller's
`--data`/`--file` JSON on top, and writes the merged result back, rather
than sending the patch alone — Invoice Ninja's `PUT` is a full replace.
See `CLAUDE.md` and the comment in `src/body.rs`.
- `--output json` on every command for the model's full `Serialize` output
(including `extra`), suitable as input to a later `update`/`create`; the
default table view is a convenience subset of columns.
- Global `--base-url`, `--token`, and `--api-password` flags, each with a
matching environment variable (`IN_BASE_URL`, `IN_API_TOKEN`,
`IN_API_PASSWORD`); both secrets are hidden from `--help` and any echoed
default.
- Depends on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk)
0.2.0 from crates.io.
- A `flake.nix` with `devShells.default`, `packages.default` (built with a
pinned toolchain), and `overlays.default` (for pulling `invoiceninja-cli`
into another flake's own `pkgs`).
- CI on GitHub Actions, mirroring `invoiceninja-sdk`'s own `ci.yml`: a
pinned-toolchain `test` job, a build-only `msrv` job on the declared 1.88
floor, and an advisory `stable` job.
+4 -2
View File
@@ -20,8 +20,10 @@ design call, the same way its counterpart in the SDK works. If a change
would contradict a stated goal or settle an open question, update would contradict a stated goal or settle an open question, update
`GOALS.md` in the same change. `GOALS.md` in the same change.
**Early scaffold.** Only the `clients` resource is wired up, as the template **Early scaffold.** Six resources are wired up (`clients`, `invoices`,
for the other ~34. See "Adding a resource" below before writing the next one. `quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the
template the other five followed. See "Adding a resource" below before
writing the next one.
## Development environment ## Development environment
+5 -3
View File
@@ -95,9 +95,11 @@ exception is written down in goal 1.
invocation. See the open question below on multi-account profiles before invocation. See the open question below on multi-account profiles before
assuming this is permanent. assuming this is permanent.
- **No stability promise before 1.0.** Same posture as the SDK: flags are a - **No stability promise before 1.0.** Same posture as the SDK: flags are a
compatibility surface too, and pre-1.0 they can change. Record breaking compatibility surface too, and pre-1.0 they can change. `CHANGELOG.md`
changes somewhere legible (a `CHANGELOG.md`, once there's enough history to records breaking changes rather than letting them be discovered at the
need one) rather than letting them be discovered at the command line. command line — keep its `[Unreleased]` section current as changes land,
and move it to a dated, versioned section at the same time `Cargo.toml`'s
version bumps, not after.
## Open questions ## Open questions
+9 -3
View File
@@ -3,9 +3,10 @@
A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/) A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/)
v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk). v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk).
> **Early scaffold.** Only the `clients` resource is wired up so far, as the > **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`,
> template for adding the rest — see `CLAUDE.md` for the pattern. > and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md`
> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not. > for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the
> crate is for and what it is not.
## Development ## Development
@@ -46,3 +47,8 @@ table view's few key columns:
```sh ```sh
invoiceninja-cli --output json clients get <id> invoiceninja-cli --output json clients get <id>
``` ```
Every other wired-up resource (`invoices`, `quotes`, `payments`, `products`,
`expenses`) follows the same `list`/`get`/`create`/`update`/`delete` shape,
e.g. `invoiceninja-cli invoices list --status active` or
`invoiceninja-cli payments get <id>`.
+13 -3
View File
@@ -45,13 +45,23 @@ pub enum OutputFormat {
Json, Json,
} }
/// One variant per resource. Only `clients` is wired up so far; it's the /// One variant per resource. Six are wired up so far (`clients` was the
/// template for adding the rest of the SDK's ~34 resources the same way — /// template for the rest); see CLAUDE.md's "Adding a resource" for the
/// see CLAUDE.md. /// remaining ~28.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Manage clients /// Manage clients
Clients(ResourceArgs), Clients(ResourceArgs),
/// Manage invoices
Invoices(ResourceArgs),
/// Manage quotes
Quotes(ResourceArgs),
/// Manage payments
Payments(ResourceArgs),
/// Manage products
Products(ResourceArgs),
/// Manage expenses
Expenses(ResourceArgs),
} }
#[derive(Args)] #[derive(Args)]
+137
View File
@@ -0,0 +1,137 @@
//! `expenses` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{Expense, ListParams};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let expenses = if args.all {
client.list_all_expenses(&params).await?
} else {
client.list_expenses(&params).await?
};
render(&expenses, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_expense(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Expense = serde_json::from_value(value)?;
let created = client.create_expense(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_expense(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Expense = serde_json::from_value(merged)?;
let updated = client.update_expense(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_expense(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(expenses: &[Expense], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(expenses)?,
OutputFormat::Table => print_table(expenses),
}
Ok(())
}
fn print_table(expenses: &[Expense]) {
if expenses.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<20} {:<12} {:>12}",
"id", "vendor_id", "client_id", "date", "amount"
);
for e in expenses {
println!(
"{:<20} {:<20} {:<20} {:<12} {:>12.2}",
e.id.as_deref().unwrap_or(""),
truncate(&e.vendor_id, 20),
truncate(&e.client_id, 20),
e.date,
e.amount
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+138
View File
@@ -0,0 +1,138 @@
//! `invoices` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{Invoice, ListParams};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let invoices = if args.all {
client.list_all_invoices(&params).await?
} else {
client.list_invoices(&params).await?
};
render(&invoices, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_invoice(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Invoice = serde_json::from_value(value)?;
let created = client.create_invoice(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_invoice(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Invoice = serde_json::from_value(merged)?;
let updated = client.update_invoice(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_invoice(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(invoices: &[Invoice], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(invoices)?,
OutputFormat::Table => print_table(invoices),
}
Ok(())
}
fn print_table(invoices: &[Invoice]) {
if invoices.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for i in invoices {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
i.id.as_deref().unwrap_or(""),
truncate(&i.number, 15),
truncate(&i.client_id, 20),
i.status_id,
i.amount,
i.balance
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+5
View File
@@ -1 +1,6 @@
pub mod clients; pub mod clients;
pub mod expenses;
pub mod invoices;
pub mod payments;
pub mod products;
pub mod quotes;
+137
View File
@@ -0,0 +1,137 @@
//! `payments` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Payment};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let payments = if args.all {
client.list_all_payments(&params).await?
} else {
client.list_payments(&params).await?
};
render(&payments, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_payment(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Payment = serde_json::from_value(value)?;
let created = client.create_payment(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_payment(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Payment = serde_json::from_value(merged)?;
let updated = client.update_payment(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_payment(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(payments: &[Payment], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(payments)?,
OutputFormat::Table => print_table(payments),
}
Ok(())
}
fn print_table(payments: &[Payment]) {
if payments.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:<12} {:>12} {:>12}",
"id", "client_id", "date", "amount", "applied"
);
for p in payments {
println!(
"{:<20} {:<20} {:<12} {:>12.2} {:>12.2}",
p.id.as_deref().unwrap_or(""),
truncate(&p.client_id, 20),
p.date,
p.amount,
p.applied
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `products` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Product};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let products = if args.all {
client.list_all_products(&params).await?
} else {
client.list_products(&params).await?
};
render(&products, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_product(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Product = serde_json::from_value(value)?;
let created = client.create_product(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_product(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Product = serde_json::from_value(merged)?;
let updated = client.update_product(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_product(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(products: &[Product], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(products)?,
OutputFormat::Table => print_table(products),
}
Ok(())
}
fn print_table(products: &[Product]) {
if products.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<20} {:>10} {:>10} {:>10}",
"id", "product_key", "price", "cost", "quantity"
);
for p in products {
println!(
"{:<20} {:<20} {:>10.2} {:>10.2} {:>10.2}",
p.id.as_deref().unwrap_or(""),
truncate(&p.product_key, 20),
p.price,
p.cost,
p.quantity
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+141
View File
@@ -0,0 +1,141 @@
//! `quotes` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. `Quote` is a type alias for `Invoice`
//! (Invoice Ninja stores quotes in the same table/schema, distinguished by
//! an `entity_type`), so this module is otherwise identical to
//! `invoices.rs` apart from which SDK methods and CLI subcommand it wires up.
use invoiceninja_sdk::models::{ListParams, Quote};
use invoiceninja_sdk::InvoiceNinjaClient;
use crate::body;
use crate::cli::{Action, BodyArgs, ListArgs, OutputFormat, ResourceArgs};
use crate::output;
pub async fn run(
client: &InvoiceNinjaClient,
args: ResourceArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match args.action {
Action::List(list_args) => list(client, list_args, format).await,
Action::Get { id } => get(client, &id, format).await,
Action::Create(body_args) => create(client, body_args, format).await,
Action::Update { id, body } => update(client, &id, body, format).await,
Action::Delete { id } => delete(client, &id).await,
}
}
async fn list(
client: &InvoiceNinjaClient,
args: ListArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let mut params = ListParams::new();
if let Some(page) = args.page {
params = params.page(page);
}
if let Some(per_page) = args.per_page {
params = params.per_page(per_page);
}
if let Some(sort) = args.sort {
params = params.sort(sort);
}
if let Some(filter) = args.filter {
params = params.filter(filter);
}
if let Some(status) = args.status {
params = params.status(status);
}
if let Some(include) = args.include {
params = params.include(include);
}
for (k, v) in args.params {
params = params.param(k, v);
}
let quotes = if args.all {
client.list_all_quotes(&params).await?
} else {
client.list_quotes(&params).await?
};
render(&quotes, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_quote(id).await?;
render(std::slice::from_ref(&found), format)
}
async fn create(
client: &InvoiceNinjaClient,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let value = body::load(&body_args)?;
let model: Quote = serde_json::from_value(value)?;
let created = client.create_quote(&model).await?;
render(std::slice::from_ref(&created), format)
}
async fn update(
client: &InvoiceNinjaClient,
id: &str,
body_args: BodyArgs,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let patch = body::load(&body_args)?;
let current = client.get_quote(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Quote = serde_json::from_value(merged)?;
let updated = client.update_quote(id, &model).await?;
render(std::slice::from_ref(&updated), format)
}
async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box<dyn std::error::Error>> {
client.delete_quote(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(quotes: &[Quote], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(quotes)?,
OutputFormat::Table => print_table(quotes),
}
Ok(())
}
fn print_table(quotes: &[Quote]) {
if quotes.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for q in quotes {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
q.id.as_deref().unwrap_or(""),
truncate(&q.number, 15),
truncate(&q.client_id, 20),
q.status_id,
q.amount,
q.balance
);
}
}
fn truncate(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_string()
} else {
s.chars().take(max.saturating_sub(1)).collect::<String>() + "…"
}
}
+5
View File
@@ -22,5 +22,10 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
match cli.command { match cli.command {
Command::Clients(args) => commands::clients::run(&client, args, cli.output).await, Command::Clients(args) => commands::clients::run(&client, args, cli.output).await,
Command::Invoices(args) => commands::invoices::run(&client, args, cli.output).await,
Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await,
Command::Payments(args) => commands::payments::run(&client, args, cli.output).await,
Command::Products(args) => commands::products::run(&client, args, cli.output).await,
Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await,
} }
} }