Files
invoiceninja-cli/src/cli.rs
T
awkawbandClaude Sonnet 5 a0a629cbfe 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>
2026-10-26 16:49:39 -04:00

151 lines
5.1 KiB
Rust

use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
/// A command-line client for the Invoice Ninja v5 API, built on the
/// `invoiceninja-sdk` crate.
#[derive(Parser)]
#[command(name = "invoiceninja-cli", version, about)]
pub struct Cli {
/// API root, including the `/api/v1` suffix. Defaults to the hosted app
/// (`invoicing.co`); a self-hosted install must pass this.
#[arg(long, env = "IN_BASE_URL", global = true)]
pub base_url: Option<String>,
/// API token. Required for every call.
///
/// Typed `Option` rather than clap-`required`: clap refuses to combine
/// `required` with `global` (needed so this can also be passed after the
/// subcommand), so the missing case is checked in `config::build_client`
/// instead, with a message that names both ways to supply it.
/// `hide_env_values` keeps it out of `--help`, which otherwise echoes
/// env-sourced defaults back verbatim.
#[arg(long, env = "IN_API_TOKEN", global = true, hide_env_values = true)]
pub token: Option<String>,
/// Account password, needed only for the endpoints Invoice Ninja marks
/// `password_protected` (reading or writing a user, purging a client).
#[arg(long, env = "IN_API_PASSWORD", global = true, hide_env_values = true)]
pub api_password: Option<String>,
#[arg(long, global = true, value_enum, default_value_t = OutputFormat::Table)]
pub output: OutputFormat,
#[command(subcommand)]
pub command: Command,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum OutputFormat {
/// A few key columns, one record per line. Which columns count as "key"
/// is a per-resource choice, made in that resource's command module.
Table,
/// The full record(s), pretty-printed. Always lossless — use this to see
/// every field a table view leaves out, including `extra`.
Json,
}
/// 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 ~23.
#[derive(Subcommand)]
pub enum Command {
/// Manage clients
Clients(ResourceArgs),
/// Manage invoices
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)]
pub struct ResourceArgs {
#[command(subcommand)]
pub action: Action,
}
/// The five actions every resource exposes. `Create`/`Update` share
/// `BodyArgs` because both take a JSON record; see `body::load`.
#[derive(Subcommand)]
pub enum Action {
/// List records (one page by default; pass --all to follow pagination)
List(ListArgs),
/// Fetch one record by id
Get { id: String },
/// Create a record from a JSON body
Create(BodyArgs),
/// Update a record from a JSON body, keeping fields you don't mention.
///
/// Fetches the record, overlays the given JSON on top, and writes the
/// merged result back — see the comment in `commands::clients::update`
/// for why a plain PUT of just the patch would blank out the rest.
Update {
id: String,
#[command(flatten)]
body: BodyArgs,
},
/// Delete (soft-delete) a record by id
Delete { id: String },
}
/// Mirrors the parameters every `ListParams` accepts in the SDK — see
/// `invoiceninja_sdk::models::ListParams`.
#[derive(Args)]
pub struct ListArgs {
#[arg(long)]
pub page: Option<u64>,
#[arg(long)]
pub per_page: Option<u64>,
#[arg(long)]
pub sort: Option<String>,
#[arg(long)]
pub filter: Option<String>,
/// e.g. "active,archived,deleted" to include archived/deleted records
#[arg(long)]
pub status: Option<String>,
/// Comma-separated relationships to eager-load, e.g. "contacts"
#[arg(long)]
pub include: Option<String>,
/// Extra query parameter, as key=value. Repeatable, for the per-resource
/// filters `ListParams::param` covers (`client_id`, `client_status`, …).
#[arg(long = "param", value_parser = parse_key_val)]
pub params: Vec<(String, String)>,
/// Follow pagination and return every matching record instead of one
/// page (`list_all_*` in the SDK rather than `list_*`).
#[arg(long)]
pub all: bool,
}
#[derive(Args)]
pub struct BodyArgs {
/// The record body, as a JSON object, given inline.
#[arg(long, conflicts_with = "file")]
pub data: Option<String>,
/// The record body, as a JSON object, read from a file ("-" for stdin).
#[arg(long, conflicts_with = "data")]
pub file: Option<PathBuf>,
}
fn parse_key_val(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected key=value, got `{s}`"))?;
Ok((k.to_string(), v.to_string()))
}