Skip to content

feat(library): top-level Fnox::discover() / get / list convenience API#442

Merged
jdx merged 5 commits intojdx:mainfrom
bglusman:feat/library-convenience-api
Apr 26, 2026
Merged

feat(library): top-level Fnox::discover() / get / list convenience API#442
jdx merged 5 commits intojdx:mainfrom
bglusman:feat/library-convenience-api

Conversation

@bglusman
Copy link
Copy Markdown
Contributor

@bglusman bglusman commented Apr 25, 2026

Per discussion #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.

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 ArcFnox: 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/zeroclawed#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.

…umers

Per discussion jdx#441 — the existing library API (Config + per-secret
SecretConfig + secret_resolver::resolve_secret) is sufficient but
CLI-shaped, so every consumer ends up replicating GetCommand::run's
boilerplate to do basic "give me this secret" work.

Adds a top-level Fnox struct in src/library.rs with:

  - Fnox::discover()             — upward fnox.toml search from CWD
  - Fnox::discover_from(start)   — same, from a specific path
  - Fnox::open(path)             — explicit config path, no search
  - Fnox::with_profile(profile)  — builder for non-default profile
  - Fnox::profile()              — read active profile
  - Fnox::config()               — borrow underlying Config for callers
                                   that need finer-grained access
  - Fnox::get(key) -> Option<String>   — resolve a secret
  - Fnox::list() -> Vec<String>        — declared secret names

Re-exported as fnox::Fnox so consumers can write three lines:

    let fnox = Fnox::discover()?;
    let value = fnox.get(\"MY_KEY\").await?;

Usage docs in module-level comment.

set() is intentionally NOT in this PR. SetCommand::run is ~100 LOC of
provider/encryption/remote-storage branching, base64 handling, and
dry-run logic — substantial enough to warrant its own design pass.
Leaving it for a follow-up PR rather than rushing it in here.

Tests: 7 new in library::tests covering discover (adjacent +
walk-up + missing), list (declaration order preserved), get (default
value resolution + clear error on undeclared key), and profile
selection. Full lib suite: 142 passed (135 existing + 7 new).
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new library module and the Fnox struct, providing a high-level convenience API for downstream consumers to load configurations and resolve secrets. The feedback suggests several improvements: ensuring that configuration loading correctly handles hierarchical merging (global/parent configs), optimizing the Fnox struct's performance by wrapping the potentially expensive Config in an Arc, simplifying the discover method by leveraging existing Config logic, and refining the get method to use more efficient lookups and specific error types.

Comment thread src/library.rs Outdated
loop {
let candidate = current.join(CONFIG_FILENAME);
if candidate.exists() {
let config = Config::load_smart(&candidate)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of discover_from (and open) has a significant logic issue regarding configuration merging. Config::load_smart only performs a recursive merge if the path matches the default filename exactly (relative to CWD). For absolute paths found during the walk, it calls Config::load, which only loads that single file. This bypasses the hierarchical merging logic (global config, parent configs, local overrides), which is critical for resolving providers and inherited secrets. For example, if a provider is defined in ~/.config/fnox/config.toml, it will not be available here. This method should ideally leverage Config's internal recursive loading logic to ensure a fully merged configuration is returned.

Comment thread src/library.rs Outdated
Comment on lines +47 to +48
/// Cheap to clone (just a [`Config`] + a [`String`] profile). Hold
/// across `.await` freely.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Config struct contains multiple IndexMap and HashMap fields, making it relatively expensive to clone (deep copy of all strings and map structures). If frequent cloning is expected for library consumers, consider wrapping Config in an Arc within the Fnox struct to make it truly cheap to clone.

Comment thread src/library.rs Outdated
Comment on lines +62 to +66
pub fn discover() -> Result<Self> {
let start = std::env::current_dir()
.map_err(|e| FnoxError::Config(format!("Failed to read current directory: {e}")))?;
Self::discover_from(&start)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This can be simplified by calling Self::open with the default config filename. Config::load_smart already handles the upward search from the current directory when given the default filename, ensuring consistency with the CLI's loading logic (including support for .fnox.toml and merging with global/local configs).

    pub fn discover() -> Result<Self> {
        Self::open(crate::config::DEFAULT_CONFIG_FILENAME)
    }

Comment thread src/library.rs Outdated
Comment on lines +126 to +132
let secrets = self.config.get_secrets(&self.profile)?;
let secret_config = secrets.get(key).ok_or_else(|| {
FnoxError::Config(format!(
"Secret '{key}' not declared in profile '{}'",
self.profile
))
})?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using self.config.get_secret is more efficient as it avoids cloning the entire IndexMap of secrets for the profile. Additionally, returning FnoxError::SecretNotFound is preferred over the generic FnoxError::Config as it provides a more specific error type for library consumers to handle.

Suggested change
let secrets = self.config.get_secrets(&self.profile)?;
let secret_config = secrets.get(key).ok_or_else(|| {
FnoxError::Config(format!(
"Secret '{key}' not declared in profile '{}'",
self.profile
))
})?;
let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| {
FnoxError::SecretNotFound {
key: key.to_string(),
profile: self.profile.clone(),
config_path: None,
suggestion: None,
}
})?;

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Apr 25, 2026

Greptile Summary

This PR adds a Fnox convenience struct in src/library.rs that wraps Arc<Config> to give downstream library consumers a clean discover() / open() / get() / list() API, re-exported as fnox::Fnox. All issues from the v1/v2 review rounds have been addressed: open calls Config::load directly (no silent discovery walk), list is synchronous, SecretNotFound carries a suggestion, and the discover_from footgun has been removed.

Confidence Score: 4/5

Safe to merge with minor follow-ups; no runtime bugs or security concerns in the changed paths

Only P2 findings remain: CONFIG_FILENAME should be re-exported from the crate root to match its own doc comment, and the regression-guard test is environment-sensitive. Neither affects runtime behavior.

No files require special attention beyond the two P2 findings noted above

Important Files Changed

Filename Overview
src/lib.rs Adds pub mod library and re-exports Fnox; CONFIG_FILENAME is not re-exported at the crate root despite the doc comment claiming it is
src/library.rs New Fnox convenience struct wrapping Arc<Config>; all v1/v2 review issues addressed — open correctly calls Config::load directly, discover delegates to Config::load_smart, list is sync, suggestions populated in SecretNotFound; regression-guard test is environment-sensitive

Sequence Diagram

sequenceDiagram
    participant C as Caller
    participant F as Fnox
    participant CS as Config::load_smart
    participant CL as Config::load
    participant SR as secret_resolver
    participant SU as suggest

    C->>F: Fnox::discover()
    F->>CS: load_smart("fnox.toml")
    CS->>CS: load_with_recursion (upward walk + merge)
    CS-->>F: Arc<Config>
    F-->>C: Fnox { config, profile }

    C->>F: Fnox::open(path)
    F->>CL: Config::load(resolved_path)
    CL-->>F: Config
    F-->>C: Fnox { config, profile }

    C->>F: fnox.get("KEY")
    F->>F: config.get_secret(profile, key)
    alt key declared
        F->>SR: resolve_secret(config, profile, key, secret_config)
        SR-->>F: Option<String>
        F-->>C: Ok(Option<String>)
    else key undeclared
        F->>F: self.list().ok()
        F->>SU: find_similar(key, declared_names)
        SU-->>F: suggestion
        F-->>C: Err(SecretNotFound { key, profile, suggestion })
    end

    C->>F: fnox.list()
    F->>F: config.get_secrets(profile)
    F-->>C: Ok(Vec<String>)
Loading

Reviews (6): Last reviewed commit: "fix(library): open() bypasses load_smart..." | Re-trigger Greptile

Comment thread src/library.rs Outdated
Comment thread src/library.rs Outdated
Comment thread src/library.rs Outdated
Comment thread src/library.rs Outdated
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new top-level Fnox convenience API intended for downstream library consumers to discover/load config once and then get/list secrets without duplicating CLI-oriented boilerplate.

Changes:

  • Introduce src/library.rs with Fnox::{discover, discover_from, open, with_profile, get, list} plus tests.
  • Export the new module and re-export Fnox from src/lib.rs.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
src/library.rs New Fnox wrapper type + discovery/loading helpers, get/list APIs, and unit tests.
src/lib.rs Exposes the new library module and re-exports Fnox at the crate root.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/library.rs Outdated
Comment on lines +72 to +80
let mut current: PathBuf = start.as_ref().to_path_buf();
loop {
let candidate = current.join(CONFIG_FILENAME);
if candidate.exists() {
let config = Config::load_smart(&candidate)?;
return Ok(Self {
config,
profile: DEFAULT_PROFILE.to_string(),
});
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discover_from finds an absolute config path and then calls Config::load_smart(&candidate). Because load_smart only enables recursive upward search/merging when the path equals a default filename (e.g. "fnox.toml"), passing an absolute path forces the non-recursive Config::load code path. This diverges from the docs here ("same path the binary takes") and from CLI behavior (global + parent + .fnox/profile/local merges, plus project_dir being set). Consider either (a) exposing a Config::load_with_recursion_from(start_dir)-style API and using that here, or (b) updating the docs/PR description to clarify that this loads only the single nearest fnox.toml without recursion/merging.

Copilot uses AI. Check for mistakes.
Comment thread src/library.rs Outdated
let mut current: PathBuf = start.as_ref().to_path_buf();
loop {
let candidate = current.join(CONFIG_FILENAME);
if candidate.exists() {
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discover_from uses candidate.exists() to decide whether it found a config file. exists() will also be true for directories named fnox.toml, which then causes Config::load_smart to attempt to read a directory as a file and return a confusing error. Using candidate.is_file() (or metadata().is_file()) would make the failure mode clearer and avoid false positives.

Suggested change
if candidate.exists() {
if candidate.is_file() {

Copilot uses AI. Check for mistakes.
Comment thread src/library.rs Outdated
Comment on lines +91 to +97
/// Open an explicit config path. Skips the upward search.
pub fn open(config_path: impl AsRef<Path>) -> Result<Self> {
let config = Config::load_smart(config_path)?;
Ok(Self {
config,
profile: DEFAULT_PROFILE.to_string(),
})
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

open() is documented as "Open an explicit config path. Skips the upward search", but it currently calls Config::load_smart(config_path). If a caller passes a default filename like "fnox.toml" (or .fnox.toml, fnox.local.toml, etc.), load_smart will enable recursive upward search/merging instead of performing a direct load. To make open() reliably non-searching, consider resolving config_path to an absolute path (if needed) and calling Config::load(...) directly.

Copilot uses AI. Check for mistakes.
Comment thread src/library.rs Outdated
Comment on lines +37 to +40
/// Default profile when callers don't specify one. Matches the
/// binary's default (no `--profile` flag → `"default"`).
pub const DEFAULT_PROFILE: &str = "default";

Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library client hard-codes the default profile to "default" (DEFAULT_PROFILE) when constructing Fnox instances. In the rest of the codebase, "default" is only the fallback after considering CLI flags and environment (e.g. Config::get_profile(...) / Settings::get().profile). With the current implementation, FNOX_PROFILE (and any future default-profile selection logic) will be ignored unless callers remember to call with_profile(...), which is a behavioral mismatch with the CLI and the comment above this const. Consider initializing profile from crate::settings::Settings::get().profile (or Config::get_profile(None)) instead of a hard-coded constant.

Suggested change
/// Default profile when callers don't specify one. Matches the
/// binary's default (no `--profile` flag → `"default"`).
pub const DEFAULT_PROFILE: &str = "default";
/// Final fallback profile when no explicit or configured profile is
/// available.
pub const DEFAULT_PROFILE: &str = "default";
/// Resolve the initial profile for library clients using the same
/// settings-based default selection as the CLI.
fn initial_profile() -> String {
crate::settings::Settings::get().profile.clone()
}

Copilot uses AI. Check for mistakes.
Comment thread src/library.rs Outdated
Comment on lines +126 to +132
let secrets = self.config.get_secrets(&self.profile)?;
let secret_config = secrets.get(key).ok_or_else(|| {
FnoxError::Config(format!(
"Secret '{key}' not declared in profile '{}'",
self.profile
))
})?;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get() calls self.config.get_secrets(&self.profile)?, which clones the full secrets IndexMap on every lookup. The codebase already has Config::get_secret(profile, key) specifically to avoid this clone for single-key lookups. Switching get() to use get_secret would reduce allocations and make repeated get() calls significantly cheaper.

Suggested change
let secrets = self.config.get_secrets(&self.profile)?;
let secret_config = secrets.get(key).ok_or_else(|| {
FnoxError::Config(format!(
"Secret '{key}' not declared in profile '{}'",
self.profile
))
})?;
let secret_config = self.config.get_secret(&self.profile, key)?;

Copilot uses AI. Check for mistakes.
Comment thread src/library.rs Outdated
Comment on lines +127 to +132
let secret_config = secrets.get(key).ok_or_else(|| {
FnoxError::Config(format!(
"Secret '{key}' not declared in profile '{}'",
self.profile
))
})?;
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the requested key is not declared, get() returns FnoxError::Config(...). Elsewhere (e.g. GetCommand::run) this condition is represented as FnoxError::SecretNotFound { .. }, which includes richer diagnostic info (config_path, suggestions, actionable help). For consistency and more useful errors to downstream callers, consider using FnoxError::SecretNotFound here as well.

Copilot uses AI. Check for mistakes.
Convergent findings across all three reviewers, fixed together:

HIGH (Gemini, Greptile, Copilot):
  discover_from() built an absolute candidate path then handed it to
  Config::load_smart, bypassing the upward-search/parent-merge/local-
  override/global-config path that load_smart only takes for the
  default filename. Per AGENTS.md "Loading order" this dropped 4 of
  the 5 documented merge layers.

  Fix: discover() now calls Config::load_smart(CONFIG_FILENAME) with
  no directory prefix, restoring the full merge chain. Removed
  discover_from() — couldn't be made safe without process-global
  std::env::set_current_dir, which violates AGENTS.md and is unsafe
  in parallel test runs anyway.

MED (Greptile): list() was async for no reason. Made it sync —
Config::get_secrets is fully synchronous.

MED (Gemini): Fnox: Clone deep-copied the entire Config (multiple
IndexMap fields). Wrapped in Arc<Config>; new test asserts clones
share the Arc rather than copying.

MED (Gemini, Copilot): get() called config.get_secrets(profile)
which clones the whole IndexMap on every lookup. Switched to
config.get_secret(profile, key) which returns Option<&SecretConfig>.

MED (Copilot): hard-coded "default" profile bypassed
Config::get_profile's CLI/env resolution chain. Now defers to
Config::get_profile(None) which honors FNOX_PROFILE — matches
binary semantics.

LOW (Copilot): missing-key error was FnoxError::Config(...). Now
returns FnoxError::SecretNotFound { key, profile, config_path,
suggestion } matching what GetCommand::run produces, so downstream
consumers can pattern-match without a wrapper-specific variant.

LOW (Gemini): discover() collapsed to a 4-line implementation that
delegates to Config::load_smart.

Tests: dropped discover_from-specific tests, added open() positive +
negative cases, kept get/list/profile coverage, added clone-sharing
assertion. 7 tests in library:: still passing; full lib suite at
142 passed (no regressions).
@bglusman
Copy link
Copy Markdown
Contributor Author

Thanks @gemini-code-assist @greptile-apps @copilot — all findings addressed in 79f1a81 (just pushed):

HIGH (Gemini, Greptile, Copilot)discover_from was bypassing load_smart's upward+local+global merge chain by passing an absolute path. Removed discover_from entirely (couldn't be made safe without a process-global set_current_dir that violates AGENTS.md). discover() now calls Config::load_smart(CONFIG_FILENAME) with no directory prefix, restoring all 5 documented merge layers from AGENTS.md "Loading order".

MED (Greptile)list() is now sync. Config::get_secrets is fully synchronous; the async was inherited noise.

MED (Gemini)Fnox now holds Arc<Config>. New test clone_does_not_deep_copy_config asserts clones share the Arc rather than deep-copying every IndexMap.

MED (Gemini, Copilot)get() switched from get_secrets(profile)?.get(key) to config.get_secret(profile, key), avoiding the per-call IndexMap clone.

MED (Copilot) — Hard-coded "default" removed; profile now resolves via Config::get_profile(None) so FNOX_PROFILE env is honored, matching binary semantics.

LOW (Copilot) — Missing-key error is now FnoxError::SecretNotFound { key, profile, config_path, suggestion } (matches GetCommand::run shape).

LOW (Gemini)discover() collapsed to a 4-line implementation that delegates to Config::load_smart.

Full lib suite: 142 passed, 0 failed. Ready for re-review.

Comment thread src/library.rs Outdated
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new high-level Fnox API in src/library.rs to simplify secret resolution for library consumers, providing methods for discovery, configuration loading, and secret retrieval. The review feedback identifies a missing discover_from method mentioned in the PR description and suggests improving the documentation for the get method's return values. Additionally, there is a recommendation to enhance the SecretNotFound error by providing fuzzy-matched suggestions for similar keys, consistent with the CLI's existing behavior.

Comment thread src/library.rs
/// the `FNOX_PROFILE` env var (matches binary semantics).
///
/// Returns [`FnoxError`] if loading/parsing fails.
pub fn discover() -> Result<Self> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The pull request description mentions the addition of Fnox::discover_from(start), but this method is missing from the implementation. Additionally, the description mentions three tests for this method which are also absent from the patch. Given that Config::load_with_recursion currently ignores its input path and always uses the current working directory, implementing this correctly might require fixing the underlying Config logic first.

Comment thread src/library.rs Outdated
Comment on lines +113 to +115
/// Resolve a secret by name. Returns the resolved value, or
/// `None` if the key is declared with `if_missing = "ignore"` and
/// has no value.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The docstring is slightly incomplete regarding the None return case. According to handle_missing_secret in secret_resolver.rs, Ok(None) is returned when if_missing is set to either "ignore" or "warn".

Suggested change
/// Resolve a secret by name. Returns the resolved value, or
/// `None` if the key is declared with `if_missing = "ignore"` and
/// has no value.
/// Resolve a secret by name. Returns the resolved value, or
/// `None` if the key is declared with `if_missing = "ignore"` or
/// `"warn"` and has no value.

Comment thread src/library.rs Outdated
Comment on lines +124 to +131
let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| {
FnoxError::SecretNotFound {
key: key.to_string(),
profile: self.profile.clone(),
config_path: self.config.secret_sources.get(key).cloned(),
suggestion: None,
}
})?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The SecretNotFound error can be made more helpful by providing suggestions for similar keys, matching the user-friendly behavior of the CLI. You can leverage the existing list() method and the crate::suggest module to populate the suggestion field.

        let secret_config = self.config.get_secret(&self.profile, key).ok_or_else(|| {
            let suggestion = self.list().ok().and_then(|names| {
                let similar = crate::suggest::find_similar(key, names.iter().map(|s| s.as_str()).collect());
                crate::suggest::format_suggestions(&similar)
            });
            FnoxError::SecretNotFound {
                key: key.to_string(),
                profile: self.profile.clone(),
                config_path: self.config.secret_sources.get(key).cloned(),
                suggestion,
            }
        })?;

Three findings on v2 of the convenience-API PR, addressed together:

P1 (greptile, src/library.rs:29):
  Module-level doc example called fnox.list() and dropped the Result.
  After list() became sync, copy-pasting the snippet wouldn't compile.
  Fixed: `let names = fnox.list()?;`

MED (gemini, src/library.rs:115):
  Docstring said "None when if_missing = ignore" but per
  handle_missing_secret the None case covers BOTH "ignore" and
  "warn". Tightened the wording.

MED (gemini, src/library.rs:131):
  SecretNotFound's `suggestion` field was always None. GetCommand::run
  populates it via crate::suggest::find_similar so the user gets a
  "Did you mean DATABASE_URL?" hint instead of opaque "not declared".
  Now mirrors the binary: list() the active profile's keys, run them
  through find_similar + format_suggestions, attach to the error.

  New test get_secret_not_found_carries_did_you_mean_suggestion locks
  the behavior in: declares DATABASE_URL/DATABASE_TOKEN/NPM_TOKEN,
  asks for "DATABASE_UR" (typo), asserts suggestion contains
  "DATABASE_URL".

  Reused the renamed get() body to use early-return instead of
  ok_or_else so the suggestion call site reads top-to-bottom.

Tests: 8 in library:: (was 7), 143 in full lib suite (was 142).

Note on gemini's HIGH at :64 (PR description still mentions
discover_from): that finding refers to my PR body text, not source —
discover_from was actually removed in 79f1a81 per v1 feedback. Will
update the PR description in a separate edit so the body matches the
final code shape.
@bglusman
Copy link
Copy Markdown
Contributor Author

v3 (c17f24a) addresses the second round of feedback:

  • P1 (greptile, line 29) — doc example now uses fnox.list()? so copy-paste compiles.
  • MED (gemini, line 115) — docstring tightened to mention both if_missing = "ignore" AND "warn" cause Ok(None).
  • MED (gemini, line 131)SecretNotFound.suggestion now populated via crate::suggest::find_similar + format_suggestions, matching GetCommand::run's "Did you mean…" UX. New test get_secret_not_found_carries_did_you_mean_suggestion covers the typo path.
  • HIGH (gemini, line 64) — re description claiming discover_from — that finding was about my PR description, not the source. discover_from was actually removed in v2 per v1's HIGH finding (load_smart upward-merge bypass). Just updated the PR body to match the final code shape.

Lib tests: 143 passed (was 142). Ready for re-review.

Comment thread src/library.rs
P1 finding from greptile on PR jdx#442 v3: Config::load_smart triggers
load_with_recursion (upward search + parent merge + local override +
global config) whenever the path argument equals one of the bare
default filenames per all_config_filenames(None). CONFIG_FILENAME is
"fnox.toml", which IS one of those. So `Fnox::open(CONFIG_FILENAME)`
silently became identical to `Fnox::discover()` — even though the
docstring promised "explicit file path, no merging".

The exposed CONFIG_FILENAME constant made `Fnox::open(fnox::CONFIG_FILENAME)`
the natural idiom, which silently did discovery. Real footgun.

Fix per greptile's recommendation: call Config::load directly
(not load_smart) in open(). Resolve relative paths against CWD
first to keep open(rel) and open(abs) consistent. Updated docstring
to be explicit: "strictly load this one file — no upward-search,
no parent-merge, no global-config layer".

New regression test:
open_with_bare_default_filename_does_not_silently_discover

It calls Fnox::open(CONFIG_FILENAME) and asserts Err — locks the
contract that open and discover are distinct paths. Test prints a
warning and skips assertion if it actually finds a fnox.toml in
CWD (test environment masking the regression we're guarding).

Lib suite: 144 passed (was 143).
@bglusman
Copy link
Copy Markdown
Contributor Author

v4 (b9f94f8) addresses greptile's P1:

Fnox::open(CONFIG_FILENAME) was silently identical to Fnox::discover() because Config::load_smart upward-recurses for any bare default filename, and CONFIG_FILENAME is "fnox.toml" — one of those default filenames. The exposed constant made Fnox::open(fnox::CONFIG_FILENAME) a natural idiom that silently discovered, which contradicted the docstring's "explicit file path, no merging" promise.

Fix: call Config::load directly in open() — bypass the smart-dispatch entirely. Resolved relative paths against CWD via crate::env::current_dir() first to keep open(rel) and open(abs) consistent. Updated docstring to be explicit: "strictly load this one file — no upward-search, no parent-merge, no global-config layer".

Regression test: open_with_bare_default_filename_does_not_silently_discover. Calls Fnox::open(CONFIG_FILENAME), asserts Err. Prints a warning + skips assertion if a real fnox.toml exists in CWD (test environment masking the regression).

Lib suite: 144 passed (was 143). Ready for re-review.

@jdx jdx merged commit 80dce4f into jdx:main Apr 26, 2026
15 checks passed
jdx pushed a commit that referenced this pull request Apr 26, 2026
### 🚀 Features

- **(library)** top-level Fnox::discover() / get / list convenience API
by [@bglusman](https://github.com/bglusman) in
[#442](#442)

### 🐛 Bug Fixes

- **(docs)** stack banner and pin close button on mobile by
[@jdx](https://github.com/jdx) in
[#437](#437)
- **(set)** fall back to current provider when updating secrets by
[@rpendleton](https://github.com/rpendleton) in
[#439](#439)

### 📚 Documentation

- **(site)** show release version and github stars by
[@jdx](https://github.com/jdx) in
[#443](#443)
- add cross-site announcement banner by [@jdx](https://github.com/jdx)
in [#434](#434)
- respect banner expires field by [@jdx](https://github.com/jdx) in
[#436](#436)

### 🛡️ Security

- **(build)** deterministic provider ordering in generated schema by
[@jdx](https://github.com/jdx) in
[#432](#432)

### 🔍 Other Changes

- **(release)** append en.dev sponsor blurb to release notes by
[@jdx](https://github.com/jdx) in
[#431](#431)

### 📦️ Dependency Updates

- bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in
[#435](#435)
- bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in
[#438](#438)

### New Contributors

- @bglusman made their first contribution in
[#442](#442)
jdx pushed a commit that referenced this pull request Apr 26, 2026
### 🚀 Features

- **(library)** top-level Fnox::discover() / get / list convenience API
by [@bglusman](https://github.com/bglusman) in
[#442](#442)

### 🐛 Bug Fixes

- **(docs)** stack banner and pin close button on mobile by
[@jdx](https://github.com/jdx) in
[#437](#437)
- **(set)** fall back to current provider when updating secrets by
[@rpendleton](https://github.com/rpendleton) in
[#439](#439)

### 📚 Documentation

- **(site)** show release version and github stars by
[@jdx](https://github.com/jdx) in
[#443](#443)
- add cross-site announcement banner by [@jdx](https://github.com/jdx)
in [#434](#434)
- respect banner expires field by [@jdx](https://github.com/jdx) in
[#436](#436)

### 🛡️ Security

- **(build)** deterministic provider ordering in generated schema by
[@jdx](https://github.com/jdx) in
[#432](#432)

### 🔍 Other Changes

- **(release)** append en.dev sponsor blurb to release notes by
[@jdx](https://github.com/jdx) in
[#431](#431)

### 📦️ Dependency Updates

- bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in
[#435](#435)
- bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in
[#438](#438)
- bump communique to 1.1.2 by [@jdx](https://github.com/jdx) in
[#444](#444)

### New Contributors

- @bglusman made their first contribution in
[#442](#442)
fullerzz pushed a commit to fullerzz/fnox-py that referenced this pull request Apr 26, 2026
## Upstream release

Bumps bundled fnox binary from 1.20.0 to 1.22.0.

**Release**: https://github.com/jdx/fnox/releases/tag/v1.22.0

## Release notes

v1.22.0 introduces a top-level library API for embedding fnox in Rust
applications, and fixes a sharp edge in `fnox set` that could turn an
encrypted secret into plaintext.

## Added

**Top-level `Fnox` library API**
([#442](jdx/fnox#442)) -- @bglusman

Downstream Rust consumers can now use fnox as a library in three lines
instead of replicating the internals of `GetCommand::run`:

```rust
use fnox::Fnox;

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

The new `Fnox` type lives in `src/library.rs` and is re-exported from
the crate root. Highlights:

- `Fnox::discover()` mirrors the binary's full config-discovery and
merge chain via `Config::load_smart`, including the `FNOX_PROFILE` env
var.
- `Fnox::open(path)` loads an explicit config without the
upward-search/merge behavior.
- `Fnox::with_profile("staging")` builder for non-default profiles.
- `get()` returns `FnoxError::SecretNotFound` with a populated "Did you
mean…" suggestion, matching the CLI's UX so callers don't need to
recompute it.
- `Fnox` is cheap to clone (`Config` is held behind an `Arc`) and safe
to hold across `.await`.

`set()` is intentionally not part of this first cut; it'll get its own
design pass.

## Fixed

**`fnox set` no longer silently downgrades encrypted secrets to
plaintext** ([#439](jdx/fnox#439)) --
@rpendleton

When multiple providers were configured without a `default_provider`,
running `fnox set` on an existing secret without `--provider` would
write the new value as plaintext while leaving the original `provider =
"..."` key in place. The next `fnox get` then failed trying to "decrypt"
a value that was no longer encrypted.

`fnox set` now reuses the secret's existing provider before falling back
to `default_provider` or plaintext, so updates stay encrypted and
readable without having to pass `--provider` on every call:

```bash
fnox set --provider age MY_SECRET "original-value"   # encrypted with age
fnox set MY_SECRET "new-value"                       # still encrypted with age
```

**Deterministic provider ordering in the generated schema**
([#432](jdx/fnox#432)) -- @jdx

Within-category provider ordering in `build/generate_providers.rs` was
inheriting `fs::read_dir` order, which is OS- and filesystem-dependent.
That non-determinism flowed into `docs/public/schema.json` and caused
autofix.ci to keep reshuffling 100+ lines between runs. A secondary sort
by provider name fixes the churn; running `fnox schema` twice now
produces byte-identical output.

**Mobile docs banner layout**
([#437](jdx/fnox#437)) -- @jdx

At `<=640px` the announcement banner now switches to a column layout
with the close button pinned to the top-right corner, instead of
cramming the message and "Read more" link onto one squeezed line.

## Changed

- Docs site nav now shows the current release version (read from
`Cargo.toml` at build time) and a GitHub star count, matching the
mise/aube docs ([#443](jdx/fnox#443)) -- @jdx
- Added a dismissible cross-site announcement banner that fetches its
config from `jdx.dev/banner.json` and respects the `expires` field
([#434](jdx/fnox#434),
[#436](jdx/fnox#436)) -- @jdx

## New Contributors

* @bglusman made their first contribution in
[#442](jdx/fnox#442)

**Full Changelog**:
jdx/fnox@v1.21.0...v1.22.0

## 💚 Sponsor fnox

fnox is maintained by [@jdx](https://github.com/jdx) under
[**en.dev**](https://en.dev) — a small independent studio building
developer tooling like [mise](https://mise.jdx.dev/),
[aube](https://aube.en.dev/), hk, and more. Keeping fnox secure,
maintained, and free is funded by sponsors.

If fnox is handling secrets or config for you or your team, please
consider [sponsoring at en.dev](https://en.dev). Sponsorships are what
let fnox stay independent and the project keep moving.

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
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]>
NorthIsUp pushed a commit to NorthIsUp/fnox that referenced this pull request Apr 28, 2026
### 🚀 Features

- **(library)** top-level Fnox::discover() / get / list convenience API
by [@bglusman](https://github.com/bglusman) in
[jdx#442](jdx#442)

### 🐛 Bug Fixes

- **(docs)** stack banner and pin close button on mobile by
[@jdx](https://github.com/jdx) in
[jdx#437](jdx#437)
- **(set)** fall back to current provider when updating secrets by
[@rpendleton](https://github.com/rpendleton) in
[jdx#439](jdx#439)

### 📚 Documentation

- **(site)** show release version and github stars by
[@jdx](https://github.com/jdx) in
[jdx#443](jdx#443)
- add cross-site announcement banner by [@jdx](https://github.com/jdx)
in [jdx#434](jdx#434)
- respect banner expires field by [@jdx](https://github.com/jdx) in
[jdx#436](jdx#436)

### 🛡️ Security

- **(build)** deterministic provider ordering in generated schema by
[@jdx](https://github.com/jdx) in
[jdx#432](jdx#432)

### 🔍 Other Changes

- **(release)** append en.dev sponsor blurb to release notes by
[@jdx](https://github.com/jdx) in
[jdx#431](jdx#431)

### 📦️ Dependency Updates

- bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in
[jdx#435](jdx#435)
- bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in
[jdx#438](jdx#438)

### New Contributors

- @bglusman made their first contribution in
[jdx#442](jdx#442)
NorthIsUp pushed a commit to NorthIsUp/fnox that referenced this pull request Apr 28, 2026
### 🚀 Features

- **(library)** top-level Fnox::discover() / get / list convenience API
by [@bglusman](https://github.com/bglusman) in
[jdx#442](jdx#442)

### 🐛 Bug Fixes

- **(docs)** stack banner and pin close button on mobile by
[@jdx](https://github.com/jdx) in
[jdx#437](jdx#437)
- **(set)** fall back to current provider when updating secrets by
[@rpendleton](https://github.com/rpendleton) in
[jdx#439](jdx#439)

### 📚 Documentation

- **(site)** show release version and github stars by
[@jdx](https://github.com/jdx) in
[jdx#443](jdx#443)
- add cross-site announcement banner by [@jdx](https://github.com/jdx)
in [jdx#434](jdx#434)
- respect banner expires field by [@jdx](https://github.com/jdx) in
[jdx#436](jdx#436)

### 🛡️ Security

- **(build)** deterministic provider ordering in generated schema by
[@jdx](https://github.com/jdx) in
[jdx#432](jdx#432)

### 🔍 Other Changes

- **(release)** append en.dev sponsor blurb to release notes by
[@jdx](https://github.com/jdx) in
[jdx#431](jdx#431)

### 📦️ Dependency Updates

- bump communique to 1.0.3 by [@jdx](https://github.com/jdx) in
[jdx#435](jdx#435)
- bump communique 1.0.3 → 1.0.4 by [@jdx](https://github.com/jdx) in
[jdx#438](jdx#438)
- bump communique to 1.1.2 by [@jdx](https://github.com/jdx) in
[jdx#444](jdx#444)

### New Contributors

- @bglusman made their first contribution in
[jdx#442](jdx#442)
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.

3 participants