Skip to content

feat(fnox): library-mode FnoxClient behind fnox-library cargo feature#45

Closed
bglusman wants to merge 3 commits intointegration/super-combinedfrom
refactor/fnox-library-mode
Closed

feat(fnox): library-mode FnoxClient behind fnox-library cargo feature#45
bglusman wants to merge 3 commits intointegration/super-combinedfrom
refactor/fnox-library-mode

Conversation

@bglusman
Copy link
Copy Markdown
Owner

Summary

Library-mode `FnoxClient` (called `FnoxLibrary`) calling the fnox crate directly, behind the `fnox-library` cargo feature. Subprocess `FnoxClient` remains the default.

Motivation

  • User feedback: "we just wrapped the logic of config loading dance and ported from binary into our wrapper script? Is that not feasible? If so we could PR all of that back to fnox"
  • Both my earlier rationale ("library API is awkward") AND the "fnox needs git dep" claim were wrong. fnox is on crates.io as 1.21.0; library mode IS feasible if we replicate the binary's config-loading dance.

What's in this PR

  • `fnox-library` cargo feature (off by default) — pulls fnox 1.21
  • `FnoxLibrary` type with `get` + `list` working through fnox's library API
  • `discover()` walks up for fnox.toml the same way the binary does
  • Smoke-test example: `cargo run --features fnox-library --example fnox_library_smoke`

What's deliberately NOT in this PR

  • `set` — fnox's `SetCommand::run` is ~100 LOC of provider/encryption logic. That's the bulk of what should land upstream as a top-level `Fnox::set(name, value)` API instead of being re-implemented per consumer. Subprocess `FnoxClient::set` still services set in the meantime.

Build cost when enabled

~30 transitive crates (AWS SDK, GCP SDK, keyring, age, etc.) and ~1m 39s added to a cold workspace build. Default-off keeps consumers that don't need fnox unaffected.

Validated

`cargo run --features fnox-library --example fnox_library_smoke` loads the local fnox.toml and lists declared secret names successfully.

Follow-up

Upstream issue/PR to fnox proposing:
```rust
let fnox = Fnox::discover()?;
let v = fnox.get("MY_KEY").await?;
fnox.set("MY_KEY", "value").await?;
fnox.list().await?;
```
…so downstream library consumers stop having to reimplement the binary's orchestration.

🤖 Generated with Claude Code

The user pushed back on the original "subprocess-only" design — they
were right that wrapping the binary's config-loading dance in our
client and using the fnox library directly is feasible AND better.
Earlier conclusion that "fnox needs git dep, not on crates.io" was
also wrong — it's on crates.io as 1.21.0 (matches git tip).

This commit lands library mode behind a feature flag so we can
validate the design without imposing the deps cost on consumers
that don't need fnox at all.

Surface:
- New `FnoxLibrary` type with same get/list semantics as the
  subprocess `FnoxClient`
- `discover()` walks up looking for fnox.toml (matches binary)
- `with_profile`/`with_timeout` builders
- `set` deliberately not implemented in this cut — fnox's
  SetCommand::run is ~100 LOC of provider/encryption logic that's
  the bulk of what should land as the upstream PR (see fnox_library.rs
  module doc). Subprocess FnoxClient still services set.

Build cost when feature on: ~30 transitive crates (AWS SDK, GCP SDK,
keyring, age) + 1m 39s cold workspace build. When feature off:
zero deps cost, FnoxLibrary is shimmed to a NotInstalled error so
callers can compile-test the type without enabling fnox.

Smoke tested:
- `cargo run --features fnox-library --example fnox_library_smoke`
  successfully loads fnox.toml + lists declared secrets in the
  default profile, sharing state with the subprocess CLI.

Next: upstream PR/issue to fnox proposing a `Fnox::discover()` →
`fnox.get/set/list` convenience API, so downstream consumers can
stop replicating the binary's orchestration.
@bglusman
Copy link
Copy Markdown
Owner Author

Upstream PR open: jdx/fnox#442 (jdx approved the design in discussion #441). Once it lands, the body of fnox_library.rs here drops from 200+ LOC of CLI orchestration to ~15 LOC delegating to fnox::Fnox::discover() / get / list. Set will follow up in a separate fnox PR.

The convenience API our discussion #441 / PR #442 proposed has landed
on our fork branch. Switch the dep to the fork and refactor
FnoxLibrary from "replicate the binary's orchestration" to "thin
error-coercion shim over fnox::Fnox".

Cargo.toml: fnox dep is now git = bglusman/fnox branch =
feat/library-convenience-api. One-line edit when jdx merges + tags
1.22+ — back to fnox = "1.22" from crates.io.

Implementation shrinks from ~150 LOC of CLI orchestration to ~50:
- discover walk → fnox::Fnox::discover() (full merge chain)
- profile resolution → fnox::Config::get_profile(None)
- secret config lookup → fnox::Fnox::get(key) (no IndexMap clone)
- decl list → fnox::Fnox::list() (sync)
- error → FnoxError::SecretNotFound (matches binary contract)

API surface kept minimal:
- new() — discover from CWD
- with_root(dir) — pin a specific dir for tests/daemons
- with_profile(p) — override (None defers to FNOX_PROFILE)
- get(name) → Result<String, FnoxError>
- list() → Result<Vec<String>, FnoxError>

set still NOT implemented — follows the same pattern as upstream
PR #442 which deferred set to a follow-up. Subprocess FnoxClient
services set in the meantime.

Smoke tested:
  cargo run --features fnox-library --example fnox_library_smoke
loads fnox.toml + lists declared secrets in the active profile.
Picks up upstream PR v3:
- Doc example fix (fnox.list()?)
- get() docstring covers both ignore + warn None cases
- SecretNotFound.suggestion populated via crate::suggest

Smoke test (fnox_library_smoke example) still passes.
@bglusman
Copy link
Copy Markdown
Owner Author

Superseded by #47 — re-applied onto fresh main (this branch was based on integration/super-combined which is now squashed into main as 9ed51fb, so a rebase here would conflict-massively for no value).

@bglusman bglusman closed this Apr 25, 2026
jdx pushed a commit to jdx/fnox that referenced this pull request Apr 26, 2026
#442)

Per [discussion #441](#441) —
"sounds fine" 👍

## What
Adds a top-level `Fnox` struct in a new `src/library.rs` module so
downstream library consumers can write three lines instead of
replicating `GetCommand::run`'s boilerplate.

```rust
use fnox::Fnox;

let fnox = Fnox::discover()?;             // walks up + merges parent + local + global
let value = fnox.get("MY_KEY").await?;
let names = fnox.list()?;
```

## Final API surface (after v3 fixes)
- `Fnox::discover()` — `Config::load_smart(CONFIG_FILENAME)` (full
upward-search + parent-merge + local-override + global merge chain)
- `Fnox::open(path)` — explicit config path
- `Fnox::with_profile(profile)` — builder for non-default profile
- `Fnox::profile()` / `Fnox::config()` — accessors
- `Fnox::get(key) -> Option<String>` — resolves via
`secret_resolver::resolve_secret`. Errors with
`FnoxError::SecretNotFound { key, profile, config_path, suggestion }`
(suggestion populated via `crate::suggest` → "Did you mean
DATABASE_URL?")
- `Fnox::list() -> Vec<String>` — declared secret names, declaration
order, **sync**

`Config` is held behind `Arc` — `Fnox: Clone` is cheap.

Re-exported as `fnox::Fnox` from `lib.rs`.

## Removed from earlier drafts
- `Fnox::discover_from(start)` — couldn't be implemented safely without
a process-global `set_current_dir` violating AGENTS.md. Removed in v2.

## What's NOT here
`set()` — `SetCommand::run` is ~100 LOC of
provider/encryption/remote-storage branching. Substantial enough to
warrant its own design pass and PR. Happy to follow up if the API shape
here lands cleanly.

## Tests
8 in `library::tests`:
- `open_loads_explicit_path` / `open_errors_when_path_missing`
- `list_returns_declared_secrets_in_declaration_order`
- `get_returns_default_value_when_no_provider`
- `get_errors_with_secret_not_found_when_key_undeclared`
- `get_secret_not_found_carries_did_you_mean_suggestion` (regression for
the suggestion field)
- `with_profile_routes_list_to_named_profile`
- `clone_does_not_deep_copy_config` (asserts Arc sharing, not deep copy)

Full `cargo test --lib`: **143 passed** (135 existing + 8 new), 0
failed.

## Review fixes (v1 → v2 → v3)
v2 (commit 79f1a81) — addressed v1 review:
- HIGH: `discover_from` was bypassing load_smart's merge chain. Removed.
- MED: `list()` made sync; `Fnox` holds `Arc<Config>`; `get()` uses
`Config::get_secret` not full clone; profile via
`Config::get_profile(None)` (honors `FNOX_PROFILE`); error is
`SecretNotFound` not `Config(...)`.

v3 (commit c17f24a) — addressed v2 review:
- P1: doc example used `fnox.list()` without `?` — fixed.
- MED: `get()` docstring tightened to mention both `if_missing = ignore
| warn`.
- MED: `SecretNotFound`'s `suggestion` field now populated via
`crate::suggest::find_similar` + `format_suggestions`. New regression
test.

## Downstream POC

[bglusman/calciforge#45](bglusman/calciforge#45)
is now using this fork branch via git dep, with the wrapper collapsed
from ~150 LOC of replicated orchestration to ~50 LOC of error coercion.

---------

Co-authored-by: admin <[email protected]>
NorthIsUp pushed a commit to NorthIsUp/fnox that referenced this pull request Apr 28, 2026
jdx#442)

Per [discussion jdx#441](jdx#441) —
"sounds fine" 👍

## What
Adds a top-level `Fnox` struct in a new `src/library.rs` module so
downstream library consumers can write three lines instead of
replicating `GetCommand::run`'s boilerplate.

```rust
use fnox::Fnox;

let fnox = Fnox::discover()?;             // walks up + merges parent + local + global
let value = fnox.get("MY_KEY").await?;
let names = fnox.list()?;
```

## Final API surface (after v3 fixes)
- `Fnox::discover()` — `Config::load_smart(CONFIG_FILENAME)` (full
upward-search + parent-merge + local-override + global merge chain)
- `Fnox::open(path)` — explicit config path
- `Fnox::with_profile(profile)` — builder for non-default profile
- `Fnox::profile()` / `Fnox::config()` — accessors
- `Fnox::get(key) -> Option<String>` — resolves via
`secret_resolver::resolve_secret`. Errors with
`FnoxError::SecretNotFound { key, profile, config_path, suggestion }`
(suggestion populated via `crate::suggest` → "Did you mean
DATABASE_URL?")
- `Fnox::list() -> Vec<String>` — declared secret names, declaration
order, **sync**

`Config` is held behind `Arc` — `Fnox: Clone` is cheap.

Re-exported as `fnox::Fnox` from `lib.rs`.

## Removed from earlier drafts
- `Fnox::discover_from(start)` — couldn't be implemented safely without
a process-global `set_current_dir` violating AGENTS.md. Removed in v2.

## What's NOT here
`set()` — `SetCommand::run` is ~100 LOC of
provider/encryption/remote-storage branching. Substantial enough to
warrant its own design pass and PR. Happy to follow up if the API shape
here lands cleanly.

## Tests
8 in `library::tests`:
- `open_loads_explicit_path` / `open_errors_when_path_missing`
- `list_returns_declared_secrets_in_declaration_order`
- `get_returns_default_value_when_no_provider`
- `get_errors_with_secret_not_found_when_key_undeclared`
- `get_secret_not_found_carries_did_you_mean_suggestion` (regression for
the suggestion field)
- `with_profile_routes_list_to_named_profile`
- `clone_does_not_deep_copy_config` (asserts Arc sharing, not deep copy)

Full `cargo test --lib`: **143 passed** (135 existing + 8 new), 0
failed.

## Review fixes (v1 → v2 → v3)
v2 (commit 79f1a81) — addressed v1 review:
- HIGH: `discover_from` was bypassing load_smart's merge chain. Removed.
- MED: `list()` made sync; `Fnox` holds `Arc<Config>`; `get()` uses
`Config::get_secret` not full clone; profile via
`Config::get_profile(None)` (honors `FNOX_PROFILE`); error is
`SecretNotFound` not `Config(...)`.

v3 (commit c17f24a) — addressed v2 review:
- P1: doc example used `fnox.list()` without `?` — fixed.
- MED: `get()` docstring tightened to mention both `if_missing = ignore
| warn`.
- MED: `SecretNotFound`'s `suggestion` field now populated via
`crate::suggest::find_similar` + `format_suggestions`. New regression
test.

## Downstream POC

[bglusman/calciforge#45](bglusman/calciforge#45)
is now using this fork branch via git dep, with the wrapper collapsed
from ~150 LOC of replicated orchestration to ~50 LOC of error coercion.

---------

Co-authored-by: admin <[email protected]>
@bglusman bglusman deleted the refactor/fnox-library-mode branch May 1, 2026 17:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant