Add credits, projects, tasks, vendors, and purchase orders

Five more resources following clients.rs's template exactly, same as
the previous batch. credits.rs is otherwise identical to quotes.rs
since Credit is also 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 five.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 16:49:39 -04:00
co-authored by Claude Sonnet 5
parent 1fbd7a7ce5
commit a0a629cbfe
11 changed files with 738 additions and 21 deletions
+9 -8
View File
@@ -12,14 +12,15 @@ command line.
### Added ### Added
- Six resources, each with `list` (with `--all` for every page, `--status`, - Eleven resources, each with `list` (with `--all` for every page,
and `--param key=value` for anything `ListParams` doesn't have a typed `--status`, and `--param key=value` for anything `ListParams` doesn't have
setter for), `get`, `create`, `update`, and `delete`: `clients`, a typed setter for), `get`, `create`, `update`, and `delete`: `clients`,
`invoices`, `quotes`, `payments`, `products`, `expenses`. `quotes` is `invoices`, `quotes`, `credits`, `payments`, `products`, `expenses`,
`invoices` under a different endpoint — Invoice Ninja stores both in the `projects`, `tasks`, `vendors`, `purchase-orders`. `quotes` and `credits`
same table, distinguished by `entity_type`, so `Quote` is a type alias for are `invoices` under different endpoints — Invoice Ninja stores all three
`Invoice` in the SDK and `commands/quotes.rs` is otherwise identical to in the same table, distinguished by `entity_type`, so `Quote` and `Credit`
`commands/invoices.rs`. are type aliases for `Invoice` in the SDK and `commands/quotes.rs` /
`commands/credits.rs` are otherwise identical to `commands/invoices.rs`.
- `update` fetches the current record, shallow-merges the caller's - `update` fetches the current record, shallow-merges the caller's
`--data`/`--file` JSON on top, and writes the merged result back, rather `--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. than sending the patch alone — Invoice Ninja's `PUT` is a full replace.
+4 -3
View File
@@ -20,9 +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.** Six resources are wired up (`clients`, `invoices`, **Early scaffold.** Eleven resources are wired up (`clients`, `invoices`,
`quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the `quotes`, `credits`, `payments`, `products`, `expenses`, `projects`,
template the other five followed. See "Adding a resource" below before `tasks`, `vendors`, `purchase_orders`) out of ~34; `clients` was the
template the other ten followed. See "Adding a resource" below before
writing the next one. writing the next one.
## Development environment ## Development environment
+10 -8
View File
@@ -3,10 +3,11 @@
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.** `clients`, `invoices`, `quotes`, `payments`, `products`, > **Early scaffold.** `clients`, `invoices`, `quotes`, `credits`, `payments`,
> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md` > `products`, `expenses`, `projects`, `tasks`, `vendors`, and
> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the > `purchase-orders` are wired up so far, out of ~34 resources — see
> crate is for and what it is not. > `CLAUDE.md` for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers
> what the crate is for and what it is not.
## Development ## Development
@@ -48,7 +49,8 @@ table view's few key columns:
invoiceninja-cli --output json clients get <id> invoiceninja-cli --output json clients get <id>
``` ```
Every other wired-up resource (`invoices`, `quotes`, `payments`, `products`, Every other wired-up resource (`invoices`, `quotes`, `credits`, `payments`,
`expenses`) follows the same `list`/`get`/`create`/`update`/`delete` shape, `products`, `expenses`, `projects`, `tasks`, `vendors`,
e.g. `invoiceninja-cli invoices list --status active` or `purchase-orders`) follows the same `list`/`get`/`create`/`update`/`delete`
`invoiceninja-cli payments get <id>`. shape, e.g. `invoiceninja-cli invoices list --status active` or
`invoiceninja-cli purchase-orders get <id>`.
+12 -2
View File
@@ -45,9 +45,9 @@ pub enum OutputFormat {
Json, Json,
} }
/// One variant per resource. Six are wired up so far (`clients` was the /// One variant per resource. Eleven are wired up so far (`clients` was the
/// template for the rest); see CLAUDE.md's "Adding a resource" for the /// template for the rest); see CLAUDE.md's "Adding a resource" for the
/// remaining ~28. /// remaining ~23.
#[derive(Subcommand)] #[derive(Subcommand)]
pub enum Command { pub enum Command {
/// Manage clients /// Manage clients
@@ -56,12 +56,22 @@ pub enum Command {
Invoices(ResourceArgs), Invoices(ResourceArgs),
/// Manage quotes /// Manage quotes
Quotes(ResourceArgs), Quotes(ResourceArgs),
/// Manage credits
Credits(ResourceArgs),
/// Manage payments /// Manage payments
Payments(ResourceArgs), Payments(ResourceArgs),
/// Manage products /// Manage products
Products(ResourceArgs), Products(ResourceArgs),
/// Manage expenses /// Manage expenses
Expenses(ResourceArgs), Expenses(ResourceArgs),
/// Manage projects
Projects(ResourceArgs),
/// Manage tasks
Tasks(ResourceArgs),
/// Manage vendors
Vendors(ResourceArgs),
/// Manage purchase orders
PurchaseOrders(ResourceArgs),
} }
#[derive(Args)] #[derive(Args)]
+140
View File
@@ -0,0 +1,140 @@
//! `credits` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md. `Credit` is a type alias for `Invoice`
//! (Invoice Ninja stores credits in the same table/schema, distinguished by
//! an `entity_type`), same as `quotes.rs`.
use invoiceninja_sdk::models::{Credit, 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 credits = if args.all {
client.list_all_credits(&params).await?
} else {
client.list_credits(&params).await?
};
render(&credits, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_credit(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: Credit = serde_json::from_value(value)?;
let created = client.create_credit(&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_credit(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Credit = serde_json::from_value(merged)?;
let updated = client.update_credit(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_credit(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(credits)?,
OutputFormat::Table => print_table(credits),
}
Ok(())
}
fn print_table(credits: &[Credit]) {
if credits.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "client_id", "status", "amount", "balance"
);
for c in credits {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
c.id.as_deref().unwrap_or(""),
truncate(&c.number, 15),
truncate(&c.client_id, 20),
c.status_id,
c.amount,
c.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,6 +1,11 @@
pub mod clients; pub mod clients;
pub mod credits;
pub mod expenses; pub mod expenses;
pub mod invoices; pub mod invoices;
pub mod payments; pub mod payments;
pub mod products; pub mod products;
pub mod projects;
pub mod purchase_orders;
pub mod quotes; pub mod quotes;
pub mod tasks;
pub mod vendors;
+137
View File
@@ -0,0 +1,137 @@
//! `projects` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Project};
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 projects = if args.all {
client.list_all_projects(&params).await?
} else {
client.list_projects(&params).await?
};
render(&projects, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_project(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: Project = serde_json::from_value(value)?;
let created = client.create_project(&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_project(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Project = serde_json::from_value(merged)?;
let updated = client.update_project(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_project(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(projects: &[Project], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(projects)?,
OutputFormat::Table => print_table(projects),
}
Ok(())
}
fn print_table(projects: &[Project]) {
if projects.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<10} {:>14}",
"id", "name", "client_id", "number", "budgeted_hours"
);
for p in projects {
println!(
"{:<20} {:<30} {:<20} {:<10} {:>14.2}",
p.id.as_deref().unwrap_or(""),
truncate(&p.name, 30),
truncate(&p.client_id, 20),
p.number,
p.budgeted_hours
);
}
}
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 @@
//! `purchase-orders` subcommand. Same shape as `clients.rs` — see the
//! "Adding a resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, PurchaseOrder};
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 purchase_orders = if args.all {
client.list_all_purchase_orders(&params).await?
} else {
client.list_purchase_orders(&params).await?
};
render(&purchase_orders, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_purchase_order(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: PurchaseOrder = serde_json::from_value(value)?;
let created = client.create_purchase_order(&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_purchase_order(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: PurchaseOrder = serde_json::from_value(merged)?;
let updated = client.update_purchase_order(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_purchase_order(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(
purchase_orders: &[PurchaseOrder],
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(purchase_orders)?,
OutputFormat::Table => print_table(purchase_orders),
}
Ok(())
}
fn print_table(purchase_orders: &[PurchaseOrder]) {
if purchase_orders.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12} {:>12}",
"id", "number", "vendor_id", "status", "amount", "balance"
);
for po in purchase_orders {
println!(
"{:<20} {:<15} {:<20} {:<8} {:>12.2} {:>12.2}",
po.id.as_deref().unwrap_or(""),
truncate(&po.number, 15),
truncate(&po.vendor_id, 20),
po.status_id,
po.amount,
po.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>() + "…"
}
}
+137
View File
@@ -0,0 +1,137 @@
//! `tasks` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Task};
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 tasks = if args.all {
client.list_all_tasks(&params).await?
} else {
client.list_tasks(&params).await?
};
render(&tasks, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_task(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: Task = serde_json::from_value(value)?;
let created = client.create_task(&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_task(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Task = serde_json::from_value(merged)?;
let updated = client.update_task(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_task(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(tasks: &[Task], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(tasks)?,
OutputFormat::Table => print_table(tasks),
}
Ok(())
}
fn print_table(tasks: &[Task]) {
if tasks.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<20} {:<8}",
"id", "description", "client_id", "project_id", "status"
);
for t in tasks {
println!(
"{:<20} {:<30} {:<20} {:<20} {:<8}",
t.id.as_deref().unwrap_or(""),
truncate(&t.description, 30),
truncate(&t.client_id, 20),
truncate(&t.project_id, 20),
t.status_id
);
}
}
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>() + "…"
}
}
+136
View File
@@ -0,0 +1,136 @@
//! `vendors` subcommand. Same shape as `clients.rs` — see the "Adding a
//! resource" section in CLAUDE.md.
use invoiceninja_sdk::models::{ListParams, Vendor};
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 vendors = if args.all {
client.list_all_vendors(&params).await?
} else {
client.list_vendors(&params).await?
};
render(&vendors, format)
}
async fn get(
client: &InvoiceNinjaClient,
id: &str,
format: OutputFormat,
) -> Result<(), Box<dyn std::error::Error>> {
let found = client.get_vendor(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: Vendor = serde_json::from_value(value)?;
let created = client.create_vendor(&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_vendor(id).await?;
let merged = body::merge_shallow(serde_json::to_value(&current)?, patch);
let model: Vendor = serde_json::from_value(merged)?;
let updated = client.update_vendor(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_vendor(id).await?;
println!("deleted {id}");
Ok(())
}
fn render(vendors: &[Vendor], format: OutputFormat) -> Result<(), Box<dyn std::error::Error>> {
match format {
OutputFormat::Json => output::print_json(vendors)?,
OutputFormat::Table => print_table(vendors),
}
Ok(())
}
fn print_table(vendors: &[Vendor]) {
if vendors.is_empty() {
println!("(no records)");
return;
}
println!(
"{:<20} {:<30} {:<20} {:<10}",
"id", "name", "city", "country_id"
);
for v in vendors {
println!(
"{:<20} {:<30} {:<20} {:<10}",
v.id.as_deref().unwrap_or(""),
truncate(&v.name, 30),
truncate(&v.city, 20),
v.country_id
);
}
}
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>() + "…"
}
}
+7
View File
@@ -24,8 +24,15 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
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::Invoices(args) => commands::invoices::run(&client, args, cli.output).await,
Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await, Command::Quotes(args) => commands::quotes::run(&client, args, cli.output).await,
Command::Credits(args) => commands::credits::run(&client, args, cli.output).await,
Command::Payments(args) => commands::payments::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::Products(args) => commands::products::run(&client, args, cli.output).await,
Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await, Command::Expenses(args) => commands::expenses::run(&client, args, cli.output).await,
Command::Projects(args) => commands::projects::run(&client, args, cli.output).await,
Command::Tasks(args) => commands::tasks::run(&client, args, cli.output).await,
Command::Vendors(args) => commands::vendors::run(&client, args, cli.output).await,
Command::PurchaseOrders(args) => {
commands::purchase_orders::run(&client, args, cli.output).await
}
} }
} }