From a0a629cbfe3131bfbb29bba6ace80a875128fea4 Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Mon, 26 Oct 2026 16:49:39 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 17 ++-- CLAUDE.md | 7 +- README.md | 18 ++-- src/cli.rs | 14 +++- src/commands/credits.rs | 140 +++++++++++++++++++++++++++++++ src/commands/mod.rs | 5 ++ src/commands/projects.rs | 137 +++++++++++++++++++++++++++++++ src/commands/purchase_orders.rs | 141 ++++++++++++++++++++++++++++++++ src/commands/tasks.rs | 137 +++++++++++++++++++++++++++++++ src/commands/vendors.rs | 136 ++++++++++++++++++++++++++++++ src/main.rs | 7 ++ 11 files changed, 738 insertions(+), 21 deletions(-) create mode 100644 src/commands/credits.rs create mode 100644 src/commands/projects.rs create mode 100644 src/commands/purchase_orders.rs create mode 100644 src/commands/tasks.rs create mode 100644 src/commands/vendors.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 324e414..e28ff94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,15 @@ command line. ### 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`. +- Eleven 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`, `credits`, `payments`, `products`, `expenses`, + `projects`, `tasks`, `vendors`, `purchase-orders`. `quotes` and `credits` + are `invoices` under different endpoints — Invoice Ninja stores all three + in the same table, distinguished by `entity_type`, so `Quote` and `Credit` + 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 `--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. diff --git a/CLAUDE.md b/CLAUDE.md index a0aac6d..08f4968 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `GOALS.md` in the same change. -**Early scaffold.** Six resources are wired up (`clients`, `invoices`, -`quotes`, `payments`, `products`, `expenses`) out of ~34; `clients` was the -template the other five followed. See "Adding a resource" below before +**Early scaffold.** Eleven resources are wired up (`clients`, `invoices`, +`quotes`, `credits`, `payments`, `products`, `expenses`, `projects`, +`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. ## Development environment diff --git a/README.md b/README.md index eb486ce..7cc32f8 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,11 @@ A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/) v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk). -> **Early scaffold.** `clients`, `invoices`, `quotes`, `payments`, `products`, -> and `expenses` are wired up so far, out of ~34 resources — see `CLAUDE.md` -> for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers what the -> crate is for and what it is not. +> **Early scaffold.** `clients`, `invoices`, `quotes`, `credits`, `payments`, +> `products`, `expenses`, `projects`, `tasks`, `vendors`, and +> `purchase-orders` are wired up so far, out of ~34 resources — see +> `CLAUDE.md` for the pattern the rest follow. [`GOALS.md`](GOALS.md) covers +> what the crate is for and what it is not. ## Development @@ -48,7 +49,8 @@ table view's few key columns: invoiceninja-cli --output json clients get ``` -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 `. +Every other wired-up resource (`invoices`, `quotes`, `credits`, `payments`, +`products`, `expenses`, `projects`, `tasks`, `vendors`, +`purchase-orders`) follows the same `list`/`get`/`create`/`update`/`delete` +shape, e.g. `invoiceninja-cli invoices list --status active` or +`invoiceninja-cli purchase-orders get `. diff --git a/src/cli.rs b/src/cli.rs index 614fc1a..433fe76 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -45,9 +45,9 @@ pub enum OutputFormat { 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 -/// remaining ~28. +/// remaining ~23. #[derive(Subcommand)] pub enum Command { /// Manage clients @@ -56,12 +56,22 @@ pub enum Command { Invoices(ResourceArgs), /// Manage quotes Quotes(ResourceArgs), + /// Manage credits + Credits(ResourceArgs), /// Manage payments Payments(ResourceArgs), /// Manage products Products(ResourceArgs), /// Manage expenses Expenses(ResourceArgs), + /// Manage projects + Projects(ResourceArgs), + /// Manage tasks + Tasks(ResourceArgs), + /// Manage vendors + Vendors(ResourceArgs), + /// Manage purchase orders + PurchaseOrders(ResourceArgs), } #[derive(Args)] diff --git a/src/commands/credits.rs b/src/commands/credits.rs new file mode 100644 index 0000000..1e5dfd3 --- /dev/null +++ b/src/commands/credits.rs @@ -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> { + 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> { + 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(¶ms).await? + } else { + client.list_credits(¶ms).await? + }; + + render(&credits, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + 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> { + 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> { + let patch = body::load(&body_args)?; + let current = client.get_credit(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, 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> { + client.delete_credit(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(credits: &[Credit], format: OutputFormat) -> Result<(), Box> { + 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::() + "…" + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index dbad742..252fe47 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,11 @@ pub mod clients; +pub mod credits; pub mod expenses; pub mod invoices; pub mod payments; pub mod products; +pub mod projects; +pub mod purchase_orders; pub mod quotes; +pub mod tasks; +pub mod vendors; diff --git a/src/commands/projects.rs b/src/commands/projects.rs new file mode 100644 index 0000000..d6e239f --- /dev/null +++ b/src/commands/projects.rs @@ -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> { + 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> { + 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(¶ms).await? + } else { + client.list_projects(¶ms).await? + }; + + render(&projects, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + 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> { + 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> { + let patch = body::load(&body_args)?; + let current = client.get_project(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, 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> { + client.delete_project(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(projects: &[Project], format: OutputFormat) -> Result<(), Box> { + 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::() + "…" + } +} diff --git a/src/commands/purchase_orders.rs b/src/commands/purchase_orders.rs new file mode 100644 index 0000000..b10e806 --- /dev/null +++ b/src/commands/purchase_orders.rs @@ -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> { + 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> { + 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(¶ms).await? + } else { + client.list_purchase_orders(¶ms).await? + }; + + render(&purchase_orders, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + 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> { + 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> { + let patch = body::load(&body_args)?; + let current = client.get_purchase_order(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, 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> { + client.delete_purchase_order(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render( + purchase_orders: &[PurchaseOrder], + format: OutputFormat, +) -> Result<(), Box> { + 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::() + "…" + } +} diff --git a/src/commands/tasks.rs b/src/commands/tasks.rs new file mode 100644 index 0000000..e76b207 --- /dev/null +++ b/src/commands/tasks.rs @@ -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> { + 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> { + 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(¶ms).await? + } else { + client.list_tasks(¶ms).await? + }; + + render(&tasks, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + 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> { + 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> { + let patch = body::load(&body_args)?; + let current = client.get_task(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, 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> { + client.delete_task(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(tasks: &[Task], format: OutputFormat) -> Result<(), Box> { + 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::() + "…" + } +} diff --git a/src/commands/vendors.rs b/src/commands/vendors.rs new file mode 100644 index 0000000..454073a --- /dev/null +++ b/src/commands/vendors.rs @@ -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> { + 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> { + 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(¶ms).await? + } else { + client.list_vendors(¶ms).await? + }; + + render(&vendors, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + 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> { + 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> { + let patch = body::load(&body_args)?; + let current = client.get_vendor(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, 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> { + client.delete_vendor(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(vendors: &[Vendor], format: OutputFormat) -> Result<(), Box> { + 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::() + "…" + } +} diff --git a/src/main.rs b/src/main.rs index 2bad49b..05ff3e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,8 +24,15 @@ async fn run() -> Result<(), Box> { 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::Credits(args) => commands::credits::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, + 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 + } } }