commit dde10e522a0061d0b3c36062dc54519406eb9afb Author: Andrew Bryant Date: Mon Oct 26 11:47:15 2026 -0400 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 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(()) +}