From dde10e522a0061d0b3c36062dc54519406eb9afb Mon Sep 17 00:00:00 2001 From: Andrew Bryant Date: Mon, 26 Oct 2026 11:47:15 -0400 Subject: [PATCH] Initial commit: clients resource scaffold over invoiceninja-sdk A command-line client for the Invoice Ninja v5 API, built on the invoiceninja-sdk crate (consumed from crates.io, 0.2.0). Only the clients resource is wired up so far, as the template the rest follow - see CLAUDE.md and GOALS.md for the reasoning behind the shape of things (the update fetch-merge-write pattern, why there's no Cargo workspace with the SDK, the local-checkout override for co-development, and the Nix flake packaging via packages.default and overlays.default). Co-Authored-By: Claude Sonnet 5 --- .cargo/config.toml.example | 14 + .editorconfig | 18 + .gitignore | 7 + CLAUDE.md | 156 +++++ Cargo.lock | 1333 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 + GOALS.md | 130 ++++ LICENSE | 21 + README.md | 48 ++ flake.lock | 82 +++ flake.nix | 89 +++ src/body.rs | 50 ++ src/cli.rs | 130 ++++ src/commands/clients.rs | 143 ++++ src/commands/mod.rs | 1 + src/config.rs | 25 + src/main.rs | 26 + src/output.rs | 9 + 18 files changed, 2308 insertions(+) create mode 100644 .cargo/config.toml.example create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 GOALS.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 src/body.rs create mode 100644 src/cli.rs create mode 100644 src/commands/clients.rs create mode 100644 src/commands/mod.rs create mode 100644 src/config.rs create mode 100644 src/main.rs create mode 100644 src/output.rs diff --git a/.cargo/config.toml.example b/.cargo/config.toml.example new file mode 100644 index 0000000..818546b --- /dev/null +++ b/.cargo/config.toml.example @@ -0,0 +1,14 @@ +# Copy this file to `.cargo/config.toml` (gitignored, so this stays +# per-developer) to build against a local `../invoiceninjars-sdk` checkout +# instead of the published crates.io release of `invoiceninja-sdk`. Useful +# for testing an SDK change here before it's published. +# +# Cargo's `paths` override requires the local checkout's `[package] version` +# in its Cargo.toml to match the version requirement in this crate's +# Cargo.toml (currently "0.2.0") — if you bump the SDK's version locally +# while working on the next release, this override stops matching until +# this crate's `Cargo.toml` is bumped to match (or the SDK version is reset). +# +# Delete `.cargo/config.toml` (or just this line) to go back to the +# published version. +paths = ["../invoiceninjars-sdk"] diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2249a2a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.rs] +indent_style = space +indent_size = 4 + +[*.{yml,yaml,toml,nix}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1852e4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +.devenv/ +.direnv/ +*.swp +/.cargo/config.toml +/result +/result-* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cc45948 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,156 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A command-line client for the [Invoice Ninja](https://www.invoiceninja.com/) +v5 API, built on [`invoiceninja-sdk`](https://crates.io/crates/invoiceninja-sdk), +consumed as a normal crates.io dependency (not a path dependency on a sibling +checkout — see "Development environment" below for how that changed). +That crate's own `GOALS.md` (at `../invoiceninjars-sdk/GOALS.md`, if you have +a checkout for reference) explains why it's a separate crate rather than a +feature of the library — dependencies, MSRV, and versioning, all pre-1.0. +Read that `GOALS.md` before making a design call here; this project exists +downstream of decisions made there. + +**This project's own [`GOALS.md`](GOALS.md) states what it is for, what it +deliberately is not, and what is still undecided** — read it before making a +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.** Only the `clients` resource is wired up, as the template +for the other ~34. See "Adding a resource" below before writing the next one. + +## Development environment + +Nix-managed, with its own `flake.nix` (not shared with the SDK's): + +```sh +nix develop +``` + +The dev toolchain was originally pinned to match `invoiceninjars-sdk`'s own +(`devVersion` in each `flake.nix`), from back when this crate needed a sibling +checkout to build at all. That's no longer a hard requirement now that the +SDK is on crates.io, but the pin hasn't been revisited — if the two drift, +nothing here forces them back in sync, since the two flakes are independent +files. + +**As of 0.2.0 (2026-10-25), the SDK is published on crates.io** and this +crate depends on it as a normal version requirement +(`invoiceninja-sdk = "0.2.0"` in `Cargo.toml`), not a path dependency. That +means: + +- No `../invoiceninjars-sdk` checkout is needed to build this crate anymore. + `cargo build` fetches `invoiceninja-sdk` from the registry like any other + dependency. +- Picking up a change made in the SDK's working copy now requires that + change to be published as a new crates.io release first by default — there + is no more "edit the sibling checkout, rebuild here" loop out of the box. + For active co-development across both crates, copy + `.cargo/config.toml.example` to `.cargo/config.toml` (gitignored, so it + stays per-developer and never affects CI or anyone else's build) to get + a `paths` override back onto `../invoiceninjars-sdk`. It requires that + checkout's `[package] version` to match this crate's `invoiceninja-sdk` + requirement in `Cargo.toml` — bump both together if the SDK's working + copy moves to an unreleased version. Delete `.cargo/config.toml` to go + back to the published release. +- Pre-1.0, a y-bump (`0.2.x` → `0.3.0`) in the SDK is a breaking release per + that crate's own `GOALS.md`. The default caret requirement already blocks + picking that up automatically; bumping the version in `Cargo.toml` is a + deliberate, reviewed step, not something `cargo update` should do on its + own. + +**Watch the SDK's own package/crate name.** It has changed at least once +already (`invoiceninjars` → `invoiceninja-sdk`, crate path `invoiceninja_sdk`) +without a corresponding change here landing first. If a build fails with "no +matching package named `invoiceninja-sdk` found" or an unresolved +`invoiceninja_sdk::` path, check the crates.io listing (or +`../invoiceninjars-sdk/Cargo.toml`'s `[package] name`, if you have a +checkout) first. + +## Common commands + +```sh +cargo build +cargo run -- clients list # needs IN_API_TOKEN, or --token +cargo fmt --check +cargo clippy --all-targets +``` + +No test suite yet — nothing here has behavior worth testing beyond what +`clap`'s derive and the SDK's own test suite already cover. Add one (clap's +`Cli::try_parse_from` is the usual way to test argument parsing without +spawning a process) when a command does more than translate flags into an +SDK call. + +## Architecture + +- `src/cli.rs` — all `clap` derive types. `Cli` holds the global + options (`--base-url`, `--token`, `--api-password`, `--output`) and the + top-level `Command` subcommand enum. **`--token` can't be clap-`required`** + because clap rejects `required` combined with `global` (global is what + lets `--token` appear after the resource subcommand too, not just before + it) — the missing case is caught in `config::build_client` instead, with a + message naming both the flag and the env var. Don't try to make it + `required` again; the comment on the field explains the panic you'd get. +- `src/config.rs` — `build_client` turns a parsed `Cli` into an + `InvoiceNinjaClient`, including the manual token check above. +- `src/body.rs` — how `create`/`update` get their JSON body (`--data` + inline, `--file` from disk or `-` for stdin, or stdin by default), plus + `merge_shallow`, a one-level-deep object overlay. +- `src/output.rs` — `print_json`, shared by every resource's `--output + json`. Table rendering is **not** here; it's per-resource (see below), + since there's no generic notion of which columns matter. +- `src/commands/clients.rs` — the one resource implemented so far, and the + template for the rest. + +### The `update` fetch-merge-write pattern + +`update` is not a thin wrapper over the SDK's `update_client`. It: + +1. Fetches the current record with `get_client`. +2. Serializes it to `serde_json::Value` and shallow-merges the caller's + `--data`/`--file` JSON on top (`body::merge_shallow`). +3. Deserializes the merged value back into the model and calls + `update_client`. + +This exists because Invoice Ninja's `PUT` is a full replace — there's no +PATCH route — and every non-`Option` field on an SDK model decodes a missing +key as that type's default (`""`, `0`, `false`; see +`invoiceninja_sdk`'s `null_to_default`). Sending the caller's patch alone +would silently blank out every field they didn't mention. **Keep this +pattern for every resource's `update`** — it's the CLI-level version of the +SDK's own "never lose data in a round trip" goal, not a one-off for clients. + +`create` has no such step: a partial JSON body decodes straight into the +model, since the same defaulting that makes a bare patch dangerous for +`update` is exactly what you want when creating a record whose fields you +haven't all thought about (the API does the equivalent server-side). + +### Adding a resource + +Each resource beyond `clients` should follow the same shape: + +1. Add a variant to `Command` in `src/cli.rs` (e.g. `Invoices(ResourceArgs)`) + — `ResourceArgs` and `Action` are already generic across resources, reuse + them as-is. +2. Add `src/commands/.rs` mirroring `clients.rs`: a `run` dispatcher + over `Action`, `list`/`get`/`create`/`update`/`delete` functions calling + the matching `list_`/`get_`/… methods on + `InvoiceNinjaClient`, and a `print_table` choosing that resource's own key + columns. +3. Register the module in `src/commands/mod.rs` and the dispatch arm in + `main.rs`'s `match cli.command`. +4. Check `../invoiceninjars-sdk/src/client.rs` for whether the resource has a + `/bulk` route (most do; a few — `client_gateway_tokens`, `companies`, + `system_logs` — don't, per that crate's `crud_methods!` vs + `resource_methods!` split). This CLI doesn't expose bulk actions yet + either way; that's a separate addition. + +Resources with entity actions, document uploads, or non-CRUD endpoints +(reports, charts, search, statics, PDF downloads, …) will need more than +this template once they're added — the SDK's own `CLAUDE.md` describes which +resources have which extras. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ad9516d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1333 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "invoiceninja-cli" +version = "0.1.0" +dependencies = [ + "clap", + "invoiceninja-sdk", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "invoiceninja-sdk" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c456829d09cb275fce553ecda78e86a076d2bc689ea23c7ec767f8bbceb861b9" +dependencies = [ + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..878bac0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "invoiceninja-cli" +version = "0.1.0" +edition = "2021" +# Matches the SDK's own MSRV. clap 4.6.7 declares 1.85 (see GOALS.md in +# invoiceninjars-sdk), which is under this, so the SDK's floor is still the +# binding one. Recheck both together if either dependency tree moves. +rust-version = "1.88" +description = "A command-line client for the Invoice Ninja v5 API, built on invoiceninja-sdk" +license = "MIT" +# Slugs checked against https://crates.io/api/v1/category_slugs, same as +# invoiceninja-sdk's own Cargo.toml. +categories = ["command-line-utilities"] +keywords = ["invoiceninja", "invoicing", "billing", "cli"] + +[dependencies] +# Published on crates.io as of 0.2.0 (2026-10-25); no longer a path +# dependency on a sibling checkout. Pre-1.0, a y-bump (0.2.x -> 0.3.0) is a +# breaking release per that crate's own GOALS.md, so stick with the default +# caret requirement rather than widening it — it already blocks that +# automatically while still taking 0.2.x patch releases. +invoiceninja-sdk = "0.2.0" +clap = { version = "4", features = ["derive", "env"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/GOALS.md b/GOALS.md new file mode 100644 index 0000000..934bc16 --- /dev/null +++ b/GOALS.md @@ -0,0 +1,130 @@ +# Goals + +What this crate is trying to be, what it is deliberately not, and what is +still open. Same purpose as `invoiceninjars-sdk`'s own `GOALS.md`: several +choices here look like oversights until you know the reasoning, and that +reasoning should live here rather than only in commit messages. + +## What this is for + +A command-line way to drive the Invoice Ninja v5 API through +`invoiceninja-sdk`, for the cases Rust code is the wrong tool for: a quick +"what does this endpoint actually return", a one-off fix from a terminal, or +a shell script that doesn't want to be a Rust program. The SDK's own +`GOALS.md` names this exact need — its `examples/` cover ad-hoc probing +today, and this crate is the standalone version of that. + +It is a client over the SDK, not a second implementation of anything the SDK +already does. If a command needs logic beyond "turn flags and JSON into one +SDK call", that is usually a sign the logic belongs in the SDK instead. + +## Goals + +### 1. Every command is a thin translation, with one documented exception + +`list`, `get`, `create`, and `delete` do nothing but turn CLI flags into a +`ListParams`/model/id and call the matching SDK method. **`update` is the +one exception**, and it stays an exception rather than a pattern to extend +casually: it fetches the record, overlays the caller's JSON on top, and +writes the merged result back, because Invoice Ninja's `PUT` is a full +replace and the SDK's models default an omitted field rather than leaving it +alone. See the comment in `src/commands/clients.rs` and the matching section +in `CLAUDE.md`. Any other deviation from "thin translation" should be +written down the same way. + +### 2. `--output json` is always the full, lossless record + +The table view is a convenience subset — whatever columns a resource's +`print_table` picks — and it is allowed to leave things out. `--output json` +is not: it is always the model's own `Serialize` output, including +`extra`, so it is a drop-in input to `--data`/`--file` for a later `update` +or `create` and a reliable thing to pipe into `jq`. This is the CLI-level +version of the SDK's "never lose data in a round trip" goal. + +### 3. One pattern across resources + +Adding a resource should be mechanical, not a new design each time: a +`Command` variant, a `src/commands/.rs` with the same +`run`/`list`/`get`/`create`/`update`/`delete`/`print_table` shape as +`clients.rs`, and a registration line in two places. `CLAUDE.md`'s "Adding a +resource" section is the checklist; if a resource needs something the +template doesn't cover (bulk, uploads, entity actions, non-CRUD endpoints), +that is scope for a deliberate addition to the template, not a one-off. + +### 4. Scriptable by default + +Errors go to stderr and a failure exits non-zero; nothing is printed to +stdout on an error path that a script could mistake for a result. Secrets +(`--token`, `--api-password`) never appear in `--help` or in any echoed +default — see `hide_env_values` on each in `src/cli.rs`. The SDK already +keeps both out of its own `Debug` output; this crate's job is not to undo +that by printing them itself. + +### 5. Inherit the SDK's safety defaults rather than re-deciding them + +This crate does not expose its own retry/timeout policy or add a write path +the SDK doesn't already consider safe. If the SDK decides `POST` isn't +replayed on a 5xx because it might double-create an invoice, that reasoning +applies here unchanged — the CLI is not a place to relax it for convenience. + +## Non-goals + +- **No Cargo workspace with the SDK.** Explained in `CLAUDE.md`: the SDK is + its own git repo with its own CI and crates.io release, mirrored read-only + to GitHub. Now that the SDK is published (0.2.0, 2026-10-25), the + dependency is a plain crates.io version requirement — there isn't even a + path dependency linking the two build graphs anymore, let alone a + workspace. This doesn't mean co-development across both crates is gone: + `.cargo/config.toml.example` documents a per-developer `paths` override + back onto a local `../invoiceninjars-sdk` checkout, gitignored so it never + becomes the default for anyone else or for CI. See `CLAUDE.md`. +- **No interactive mode or TUI.** Every command should work non-interactively + from a script; a prompt-driven mode would be a different tool. +- **No local state, cache, or config file — yet.** Credentials and the base + URL come from flags or environment variables only, resolved fresh on every + invocation. See the open question below on multi-account profiles before + assuming this is permanent. +- **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 + changes somewhere legible (a `CHANGELOG.md`, once there's enough history to + need one) rather than letting them be discovered at the command line. + +## Open questions + +These are genuinely undecided; deciding one is a change to this file. + +- **Bulk actions.** The SDK exposes `bulk_` (archive/restore/delete + plus resource-specific actions) on every resource with a `/bulk` route. + Nothing here calls it yet. Worth having, but a bulk `delete` from a CLI + invites a worse mistake than a one-record `delete` — whether that needs a + confirmation prompt, a `--yes` flag, or a dry-run preview is unresolved. +- **`--dry-run` for `create`/`update`/`delete`.** Would print what the merged + body or the target of a delete would be without sending the request. + Cheap for `create`/`delete`; for `update` it would mean exposing the + fetch-merge result for inspection before the write half runs. +- **Multi-account profiles.** Flags and env vars cover one account per + invocation. A config file keyed by profile name (base URL + token) would + help anyone working against more than one instance regularly, at the cost + of being the first place this crate stores anything on disk. +- **How closely should list flags track `ListParams`?** Right now `clients + list` exposes exactly what `ListParams` has typed setters for, plus + `--param key=value` for the rest. A per-resource convenience filter (e.g. + a `--unpaid` shorthand for `client_status=unpaid`) would read nicer but + means hand-picking which filters earn a dedicated flag per resource. +- **Whether this crate gets its own CI.** No longer blocked on needing a + sibling checkout — the SDK dependency switched from a path to a crates.io + version requirement (`invoiceninja-sdk = "0.2.0"`) once 0.2.0 was published + on 2026-10-25. A workflow here can now just `cargo build` against the + registry like any other Rust project; it doesn't need to check out + `invoiceninjars-sdk` at all. +- **Whether the Nix packaging goes further than this repo's own flake.** + `flake.nix` now has `packages.default`/`devShells.default` (built with a + pinned `rust-overlay` toolchain, for parity between `nix build` and + `nix develop`) and `overlays.default` (for another flake to pull + `invoiceninja-cli` into its own `pkgs`, built with *that* nixpkgs's own + `rustPlatform` instead of the pin). The overlay path means this crate's + `rust-version = "1.88"` has to be met by whatever rustc the consuming + channel ships — there's no guard here if it isn't, just a build failure + over there. Whether that's worth submitting to nixpkgs proper (and how an + MSRV bump would then need to be communicated beyond this repo) is + unresolved. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e3ec1d1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bryant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2dc2fa4 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# invoiceninja-cli + +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.** Only the `clients` resource is wired up so far, as the +> template for adding the rest — see `CLAUDE.md` for the pattern. +> [`GOALS.md`](GOALS.md) covers what the crate is for and what it is not. + +## Development + +Enter the dev shell (Rust toolchain via `rust-overlay`, matching the SDK's): + +```sh +nix develop +``` + +## Usage + +Every subcommand needs an API token and, for a self-hosted install, a base +URL. Both can be passed as flags or environment variables (the same names +the SDK's own examples use): + +```sh +export IN_API_TOKEN=... +# export IN_BASE_URL=https://your-domain.example/api/v1 # self-hosted only + +invoiceninja-cli clients list +invoiceninja-cli clients list --all --status active,archived +invoiceninja-cli clients get +invoiceninja-cli clients create --data '{"name": "Acme Inc"}' +invoiceninja-cli clients update --data '{"city": "Springfield"}' +invoiceninja-cli clients delete +``` + +`create` and `update` read the record body as JSON, from `--data` inline, +`--file ` (`-` for stdin), or stdin if neither flag is given. +`update` fetches the current record first and overlays the given fields on +top of it, so you only need to mention what's changing — see the comment in +`src/body.rs` for why that matters (Invoice Ninja's `PUT` is a full replace, +not a patch). + +Add `--output json` to any command for the full record instead of the +table view's few key columns: + +```sh +invoiceninja-cli --output json clients get +``` diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3f66d60 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1791914995, + "narHash": "sha256-Zf/xfQEAasnwnmEfHqW/4hjDE/s44yn2hD4RuVIZRjA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "b7a79d3fea1e214f6d668d521cecec0c01e26544", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792773339, + "narHash": "sha256-rZLrzrC6i+jnTAlvPZHogKDulY7WWDEv9hJeHieO7Zw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "3ec636362e1816bc2f0888fa0fa2c69f1bc9e504", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1792913333, + "narHash": "sha256-wjoZGhxH/Yr+OsFRpNyV9DpMGQIdNEjjAOinfzQvadk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "fad80eb74614f7dc37c1c83df8caae64361eda22", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7238f2f --- /dev/null +++ b/flake.nix @@ -0,0 +1,89 @@ +{ + description = "invoiceninja-cli: a command-line client for the Invoice Ninja v5 API"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + let + cargoToml = builtins.fromTOML (builtins.readFile ./Cargo.toml); + + # Shared between `packages.default` (built below with the pinned + # rust-overlay toolchain, for parity with `nix develop`) and + # `overlays.default` (built with whatever `rustPlatform` the + # *consuming* nixpkgs provides, per the usual nixpkgs packaging + # convention — see the note on `overlays.default` below). Takes its + # inputs as a plain attrset so both call sites, and `callPackage`, + # can hand it the pieces however they're sourced. + mkInvoiceninjaCli = { lib, rustPlatform, pkg-config, openssl }: + rustPlatform.buildRustPackage { + pname = "invoiceninja-cli"; + version = cargoToml.package.version; + src = self; + cargoLock.lockFile = ./Cargo.lock; + nativeBuildInputs = [ pkg-config ]; + buildInputs = [ openssl ]; + + meta = { + description = cargoToml.package.description; + license = lib.licenses.mit; + mainProgram = "invoiceninja-cli"; + platforms = lib.platforms.unix; + }; + }; + in + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + + # Kept in step with invoiceninjars-sdk's own `devVersion`, so the two + # sibling crates build with the same toolchain during development. + devVersion = "1.99.1"; + + rustToolchain = pkgs.rust-bin.stable.${devVersion}.default.override { + extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ]; + }; + + # Built with the pinned dev toolchain, not nixpkgs' own rustc, so a + # `nix build` matches what `nix develop` compiles with. + rustPlatform = pkgs.makeRustPlatform { + cargo = rustToolchain; + rustc = rustToolchain; + }; + in + { + devShells.default = pkgs.mkShell { + packages = [ rustToolchain pkgs.pkg-config pkgs.openssl ]; + RUST_SRC_PATH = "${rustToolchain}/lib/rustlib/src/rust/library"; + }; + + # Unlike invoiceninjars-sdk, this crate has a `[[bin]]` target, so + # `nix build` produces an installable binary, not just a test gate. + # `invoiceninja-sdk` resolves from crates.io (see Cargo.toml), so + # unlike before this no longer needs a `../invoiceninjars-sdk` + # checkout to be present in the sandbox. + packages.default = mkInvoiceninjaCli { + inherit (pkgs) lib pkg-config openssl; + inherit rustPlatform; + }; + }) // { + # For a flake that wants `invoiceninja-cli` in its own `pkgs`, not for + # this flake's own `packages.default`/`devShells.default` above (those + # keep the pinned rust-overlay toolchain). `callPackage` resolves + # `rustPlatform` from the *consuming* nixpkgs, which means it builds + # with whatever rustc that channel ships — this crate's `rust-version + # = "1.88"` in Cargo.toml has to be met there too, same as any other + # nixpkgs Rust package; this overlay doesn't carry the rust-overlay + # pin along with it. + overlays.default = final: _prev: { + invoiceninja-cli = final.callPackage mkInvoiceninjaCli { }; + }; + }; +} diff --git a/src/body.rs b/src/body.rs new file mode 100644 index 0000000..28177fe --- /dev/null +++ b/src/body.rs @@ -0,0 +1,50 @@ +use std::fs; +use std::io::Read; + +use serde_json::Value; + +use crate::cli::BodyArgs; + +/// Reads the JSON body a `create`/`update` command was given: `--data` +/// inline, `--file` from disk (`-` for stdin), or stdin if neither flag was +/// passed. Errors if the result isn't a JSON object — every create/update +/// endpoint here takes a record, not a bare array or scalar. +pub fn load(body: &BodyArgs) -> Result> { + let text = match (&body.data, &body.file) { + (Some(data), None) => data.clone(), + (None, Some(path)) if path.as_os_str() == "-" => read_stdin()?, + (None, Some(path)) => fs::read_to_string(path)?, + (None, None) => read_stdin()?, + // clap's `conflicts_with` on both fields rules this out before we get here. + (Some(_), Some(_)) => unreachable!("--data and --file are mutually exclusive"), + }; + + let value: Value = serde_json::from_str(&text)?; + if !value.is_object() { + return Err(format!("expected a JSON object, got {value}").into()); + } + Ok(value) +} + +fn read_stdin() -> Result> { + let mut buf = String::new(); + std::io::stdin().read_to_string(&mut buf)?; + Ok(buf) +} + +/// Overlays `patch`'s keys onto `base`, one level deep. +/// +/// `update` uses this so a caller can send just the fields they're changing +/// instead of retyping the whole record. That matters because Invoice +/// Ninja's `PUT` is a full replace — there's no PATCH route — and the SDK's +/// models default any field the caller omits to `""` / `0` / `false` rather +/// than leaving it alone. Sending the patch by itself would blank out every +/// field it didn't mention; merging onto a freshly-fetched copy keeps them. +pub fn merge_shallow(mut base: Value, patch: Value) -> Value { + if let (Some(base_map), Value::Object(patch_map)) = (base.as_object_mut(), patch) { + for (k, v) in patch_map { + base_map.insert(k, v); + } + } + base +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..d6bce8a --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,130 @@ +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, + + /// 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, + + /// 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, + + #[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. Only `clients` is wired up so far; it's the +/// template for adding the rest of the SDK's ~34 resources the same way — +/// see CLAUDE.md. +#[derive(Subcommand)] +pub enum Command { + /// Manage clients + Clients(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, + #[arg(long)] + pub per_page: Option, + #[arg(long)] + pub sort: Option, + #[arg(long)] + pub filter: Option, + /// e.g. "active,archived,deleted" to include archived/deleted records + #[arg(long)] + pub status: Option, + /// Comma-separated relationships to eager-load, e.g. "contacts" + #[arg(long)] + pub include: Option, + /// 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, + /// The record body, as a JSON object, read from a file ("-" for stdin). + #[arg(long, conflicts_with = "data")] + pub file: Option, +} + +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())) +} diff --git a/src/commands/clients.rs b/src/commands/clients.rs new file mode 100644 index 0000000..afe3c1a --- /dev/null +++ b/src/commands/clients.rs @@ -0,0 +1,143 @@ +//! `clients` subcommand. First resource wired up, and the template for +//! adding the rest — see the "Adding a resource" section in CLAUDE.md. + +use invoiceninja_sdk::models::{Client, 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 clients = if args.all { + client.list_all_clients(¶ms).await? + } else { + client.list_clients(¶ms).await? + }; + + render(&clients, format) +} + +async fn get( + client: &InvoiceNinjaClient, + id: &str, + format: OutputFormat, +) -> Result<(), Box> { + let found = client.get_client(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)?; + // Missing fields in `value` decode fine: every non-Option field on + // `Client` is `#[serde(default, deserialize_with = "null_to_default")]`, + // so a partial object (e.g. just `{"name": "..."}`) fills the rest with + // the model's defaults, same as the API does server-side on create. + let model: Client = serde_json::from_value(value)?; + let created = client.create_client(&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_client(id).await?; + let merged = body::merge_shallow(serde_json::to_value(¤t)?, patch); + let model: Client = serde_json::from_value(merged)?; + let updated = client.update_client(id, &model).await?; + render(std::slice::from_ref(&updated), format) +} + +async fn delete(client: &InvoiceNinjaClient, id: &str) -> Result<(), Box> { + client.delete_client(id).await?; + println!("deleted {id}"); + Ok(()) +} + +fn render(clients: &[Client], format: OutputFormat) -> Result<(), Box> { + match format { + OutputFormat::Json => output::print_json(clients)?, + OutputFormat::Table => print_table(clients), + } + Ok(()) +} + +/// The columns considered "key" for a client. Every resource module picks +/// its own this way — there's no generic notion of which fields matter +/// enough for a table view. +fn print_table(clients: &[Client]) { + if clients.is_empty() { + println!("(no records)"); + return; + } + println!( + "{:<20} {:<30} {:>12} {:>12}", + "id", "name", "balance", "paid_to_date" + ); + for c in clients { + println!( + "{:<20} {:<30} {:>12.2} {:>12.2}", + c.id.as_deref().unwrap_or(""), + truncate(&c.name, 30), + c.balance, + c.paid_to_date + ); + } +} + +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 new file mode 100644 index 0000000..705f46d --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1 @@ +pub mod clients; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1627105 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,25 @@ +use invoiceninja_sdk::{InvoiceNinjaClient, HOSTED_BASE_URL}; + +use crate::cli::Cli; + +/// Builds the SDK client from the parsed CLI args: the base URL (defaulting +/// to the hosted app), the token, and the optional account password for the +/// `password_protected` endpoints. +/// +/// Checks for a missing token itself rather than leaning on clap, since +/// `token` can't be marked clap-`required` — see the comment on `Cli::token`. +pub fn build_client(cli: &Cli) -> Result> { + let token = cli + .token + .clone() + .ok_or("missing API token: pass --token or set IN_API_TOKEN")?; + let base_url = cli + .base_url + .clone() + .unwrap_or_else(|| HOSTED_BASE_URL.to_string()); + let client = InvoiceNinjaClient::new(base_url, token)?; + match &cli.api_password { + Some(password) => Ok(client.with_api_password(password.clone())?), + None => Ok(client), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..25e25e4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,26 @@ +mod body; +mod cli; +mod commands; +mod config; +mod output; + +use clap::Parser; + +use cli::{Cli, Command}; + +#[tokio::main] +async fn main() { + if let Err(err) = run().await { + eprintln!("error: {err}"); + std::process::exit(1); + } +} + +async fn run() -> Result<(), Box> { + let cli = Cli::parse(); + let client = config::build_client(&cli)?; + + match cli.command { + Command::Clients(args) => commands::clients::run(&client, args, cli.output).await, + } +} diff --git a/src/output.rs b/src/output.rs new file mode 100644 index 0000000..ab8eb40 --- /dev/null +++ b/src/output.rs @@ -0,0 +1,9 @@ +use serde::Serialize; + +/// Prints a value as pretty JSON. Used for every resource's `--output json` +/// — the table view is per-resource (see `commands::clients::print_table`), +/// but JSON formatting isn't, since it's just the model's own `Serialize`. +pub fn print_json(value: &T) -> Result<(), Box> { + println!("{}", serde_json::to_string_pretty(value)?); + Ok(()) +}