2222//! # async fn run() -> fnox::Result<()> {
2323//! use fnox::Fnox;
2424//!
25- //! // Walks up from CWD to find fnox.toml — same as the binary.
25+ //! // Walks up from CWD to find fnox.toml + merges parent + local +
26+ //! // global config — same exact merge the binary does.
2627//! let fnox = Fnox::discover()?;
2728//! let value = fnox.get("MY_KEY").await?;
28- //! let names = fnox.list().await? ;
29+ //! let names = fnox.list();
2930//! # Ok(()) }
3031//! ```
3132
32- use std:: path:: { Path , PathBuf } ;
33+ use std:: path:: Path ;
34+ use std:: sync:: Arc ;
3335
3436use crate :: config:: Config ;
3537use crate :: error:: { FnoxError , Result } ;
3638
37- /// Default profile when callers don't specify one. Matches the
38- /// binary's default (no `--profile` flag → `"default"`).
39- pub const DEFAULT_PROFILE : & str = "default" ;
40-
4139/// Filename the binary discovers via upward search. Re-exported so
4240/// callers can probe with the same name fnox itself uses.
4341pub const CONFIG_FILENAME : & str = "fnox.toml" ;
4442
4543/// Convenience client over [`Config`] — load once, query many.
4644///
47- /// Cheap to clone (just a [`Config`] + a [`String`] profile). Hold
48- /// across `.await` freely.
45+ /// Cheap to clone (Config is held behind an [`Arc`]); hold across
46+ /// `.await` freely.
4947#[ derive( Debug , Clone ) ]
5048pub struct Fnox {
51- config : Config ,
49+ config : Arc < Config > ,
5250 profile : String ,
5351}
5452
5553impl Fnox {
56- /// Walk up from the current directory looking for `fnox.toml`.
57- /// Loads it via [`Config::load_smart`] — the same path the binary
58- /// takes when invoked without an explicit `--config` flag.
54+ /// Walk up from the current directory looking for `fnox.toml`
55+ /// AND merge in the parent / local-override / global config chain
56+ /// — same exact behavior as the binary when invoked without an
57+ /// explicit `--config` flag (see `Config::load_smart` for the
58+ /// merge order).
59+ ///
60+ /// Profile is resolved via [`Config::get_profile`] which honors
61+ /// the `FNOX_PROFILE` env var (matches binary semantics).
5962 ///
60- /// Returns [`FnoxError`] if no `fnox.toml` is found above CWD or
61- /// if loading/parsing fails.
63+ /// Returns [`FnoxError`] if loading/parsing fails.
6264 pub fn discover ( ) -> Result < Self > {
63- let start = std:: env:: current_dir ( )
64- . map_err ( |e| FnoxError :: Config ( format ! ( "Failed to read current directory: {e}" ) ) ) ?;
65- Self :: discover_from ( & start)
66- }
67-
68- /// Like [`Fnox::discover`] but starts the upward search from a
69- /// specific path. Useful for tests, daemons running in a different
70- /// CWD than the project root, etc.
71- pub fn discover_from ( start : impl AsRef < Path > ) -> Result < Self > {
72- let mut current: PathBuf = start. as_ref ( ) . to_path_buf ( ) ;
73- loop {
74- let candidate = current. join ( CONFIG_FILENAME ) ;
75- if candidate. exists ( ) {
76- let config = Config :: load_smart ( & candidate) ?;
77- return Ok ( Self {
78- config,
79- profile : DEFAULT_PROFILE . to_string ( ) ,
80- } ) ;
81- }
82- if !current. pop ( ) {
83- return Err ( FnoxError :: Config ( format ! (
84- "No {CONFIG_FILENAME} found above {}" ,
85- start. as_ref( ) . display( )
86- ) ) ) ;
87- }
88- }
65+ // CONFIG_FILENAME is bare (no directory prefix) so load_smart
66+ // takes its upward-recursion path — this is what unlocks the
67+ // parent + local + global merging that load(absolute) would
68+ // bypass. Per AGENTS.md "Loading order".
69+ let config = Config :: load_smart ( CONFIG_FILENAME ) ?;
70+ let profile = Config :: get_profile ( None ) ;
71+ Ok ( Self {
72+ config : Arc :: new ( config) ,
73+ profile,
74+ } )
8975 }
9076
91- /// Open an explicit config path. Skips the upward search.
92- pub fn open ( config_path : impl AsRef < Path > ) -> Result < Self > {
93- let config = Config :: load_smart ( config_path) ?;
77+ /// Open a fnox config from a specific path. Use this when you
78+ /// have an explicit path (CLI arg, env var, daemon configuration)
79+ /// rather than wanting the binary's discovery walk.
80+ ///
81+ /// Note: `Config::load_smart` may still trigger upward-search/merge
82+ /// behavior if `path` is exactly the default filename
83+ /// ([`CONFIG_FILENAME`], no directory). Pass an absolute or
84+ /// directory-prefixed path to get strictly that file.
85+ pub fn open ( path : impl AsRef < Path > ) -> Result < Self > {
86+ let config = Config :: load_smart ( path) ?;
87+ let profile = Config :: get_profile ( None ) ;
9488 Ok ( Self {
95- config,
96- profile : DEFAULT_PROFILE . to_string ( ) ,
89+ config : Arc :: new ( config ) ,
90+ profile,
9791 } )
9892 }
9993
100- /// Use a specific profile instead of `default`. Builder-style.
94+ /// Use a specific profile instead of whatever
95+ /// [`Config::get_profile`] resolved. Builder-style.
10196 pub fn with_profile ( mut self , profile : impl Into < String > ) -> Self {
10297 self . profile = profile. into ( ) ;
10398 self
@@ -109,8 +104,8 @@ impl Fnox {
109104 }
110105
111106 /// Borrow the underlying [`Config`] for callers that need
112- /// finer-grained access (e.g., enumerating providers, walking
113- /// secret metadata) without re-parsing.
107+ /// finer-grained access (enumerating providers, walking secret
108+ /// metadata) without re-parsing.
114109 pub fn config ( & self ) -> & Config {
115110 & self . config
116111 }
@@ -119,26 +114,32 @@ impl Fnox {
119114 /// `None` if the key is declared with `if_missing = "ignore"` and
120115 /// has no value.
121116 ///
122- /// Returns an error if the key isn't declared in the active
123- /// profile, or if the configured backend fails (network error,
124- /// auth failure, decryption failure, etc.).
117+ /// Returns [`FnoxError::SecretNotFound`] if the key isn't declared
118+ /// in the active profile (matches the binary's error shape so
119+ /// downstream consumers can pattern-match without a wrapper-
120+ /// specific variant).
125121 pub async fn get ( & self , key : & str ) -> Result < Option < String > > {
126- let secrets = self . config . get_secrets ( & self . profile ) ?;
127- let secret_config = secrets. get ( key) . ok_or_else ( || {
128- FnoxError :: Config ( format ! (
129- "Secret '{key}' not declared in profile '{}'" ,
130- self . profile
131- ) )
122+ // get_secret returns Option<&SecretConfig> without cloning the
123+ // whole IndexMap — preferred over get_secrets(profile)?.get(key).
124+ let secret_config = self . config . get_secret ( & self . profile , key) . ok_or_else ( || {
125+ FnoxError :: SecretNotFound {
126+ key : key. to_string ( ) ,
127+ profile : self . profile . clone ( ) ,
128+ config_path : self . config . secret_sources . get ( key) . cloned ( ) ,
129+ suggestion : None ,
130+ }
132131 } ) ?;
133132 crate :: secret_resolver:: resolve_secret ( & self . config , & self . profile , key, secret_config)
134133 . await
135134 }
136135
137136 /// Declared secret names for the active profile, in declaration
138- /// order. Note this is the *declared* set from `fnox.toml`, not
139- /// necessarily the set of secrets that currently have a resolvable
140- /// value (some may be `if_missing = "ignore"`).
141- pub async fn list ( & self ) -> Result < Vec < String > > {
137+ /// order. Synchronous: this is a config-walk, no I/O.
138+ ///
139+ /// Note: this is the *declared* set from `fnox.toml` (and merged
140+ /// configs), not necessarily the set of secrets that currently
141+ /// have a resolvable value.
142+ pub fn list ( & self ) -> Result < Vec < String > > {
142143 let secrets = self . config . get_secrets ( & self . profile ) ?;
143144 Ok ( secrets. keys ( ) . cloned ( ) . collect ( ) )
144145 }
@@ -151,50 +152,39 @@ mod tests {
151152 use tempfile:: TempDir ;
152153
153154 /// Given a tempdir containing a minimal fnox.toml,
154- /// when discover_from () is called pointing at the dir ,
155- /// then it loads successfully with the default profile .
155+ /// when open () is called with the explicit path ,
156+ /// then it loads successfully.
156157 #[ test]
157- fn discover_from_finds_adjacent_fnox_toml ( ) {
158+ fn open_loads_explicit_path ( ) {
158159 let dir = TempDir :: new ( ) . unwrap ( ) ;
159- fs:: write ( dir. path ( ) . join ( CONFIG_FILENAME ) , "" ) . unwrap ( ) ;
160+ let path = dir. path ( ) . join ( CONFIG_FILENAME ) ;
161+ fs:: write ( & path, "" ) . unwrap ( ) ;
160162
161- let fnox = Fnox :: discover_from ( dir. path ( ) ) . expect ( "discover should succeed" ) ;
162- assert_eq ! ( fnox. profile( ) , DEFAULT_PROFILE ) ;
163+ let fnox = Fnox :: open ( & path) . expect ( "open should succeed" ) ;
164+ // Whatever Config::get_profile(None) resolves to (default or
165+ // FNOX_PROFILE) — just assert non-empty for stability across
166+ // test environments.
167+ assert ! ( !fnox. profile( ) . is_empty( ) ) ;
163168 }
164169
165- /// Given a tempdir containing fnox.toml at the parent ,
166- /// when discover_from () starts in a child subdirectory ,
167- /// then it walks up and finds the parent's fnox.toml .
170+ /// Given a path that doesn't exist ,
171+ /// when open () is called ,
172+ /// then it returns a clear error .
168173 #[ test]
169- fn discover_from_walks_up_to_parent_fnox_toml ( ) {
174+ fn open_errors_when_path_missing ( ) {
170175 let dir = TempDir :: new ( ) . unwrap ( ) ;
171- fs:: write ( dir. path ( ) . join ( CONFIG_FILENAME ) , "" ) . unwrap ( ) ;
172- let child = dir. path ( ) . join ( "a/b/c" ) ;
173- fs:: create_dir_all ( & child) . unwrap ( ) ;
174-
175- let fnox = Fnox :: discover_from ( & child) . expect ( "upward search should succeed" ) ;
176- assert_eq ! ( fnox. profile( ) , DEFAULT_PROFILE ) ;
176+ let missing = dir. path ( ) . join ( "does-not-exist.toml" ) ;
177+ let err = Fnox :: open ( & missing) . expect_err ( "must fail" ) ;
178+ // Accept any error shape — the contract is "fails", not the
179+ // exact message text.
180+ let _ = err. to_string ( ) ;
177181 }
178182
179- /// Given a tempdir with NO fnox.toml,
180- /// when discover_from() is called,
181- /// then it returns a clear error naming the start path.
182- #[ test]
183- fn discover_from_errors_with_clear_message_when_no_config ( ) {
184- let dir = TempDir :: new ( ) . unwrap ( ) ;
185- let err = Fnox :: discover_from ( dir. path ( ) ) . expect_err ( "should fail" ) ;
186- let msg = err. to_string ( ) ;
187- assert ! (
188- msg. contains( CONFIG_FILENAME ) ,
189- "error must name the config filename so users know what's missing; got: {msg}"
190- ) ;
191- }
192-
193- /// Given a fnox.toml declaring two secrets in default profile,
183+ /// Given a fnox.toml declaring two secrets in default,
194184 /// when list() is called,
195185 /// then both names come back, in declaration order.
196- #[ tokio :: test]
197- async fn list_returns_declared_secrets_in_declaration_order ( ) {
186+ #[ test]
187+ fn list_returns_declared_secrets_in_declaration_order ( ) {
198188 let dir = TempDir :: new ( ) . unwrap ( ) ;
199189 fs:: write (
200190 dir. path ( ) . join ( CONFIG_FILENAME ) ,
@@ -206,8 +196,8 @@ ASECOND = { default = "second-default" }
206196 )
207197 . unwrap ( ) ;
208198
209- let fnox = Fnox :: discover_from ( dir. path ( ) ) . unwrap ( ) ;
210- let names = fnox. list ( ) . await . unwrap ( ) ;
199+ let fnox = Fnox :: open ( dir. path ( ) . join ( CONFIG_FILENAME ) ) . unwrap ( ) ;
200+ let names = fnox. list ( ) . unwrap ( ) ;
211201 assert_eq ! (
212202 names,
213203 vec![ "ZFIRST" . to_string( ) , "ASECOND" . to_string( ) ] ,
@@ -217,60 +207,56 @@ ASECOND = { default = "second-default" }
217207
218208 /// Given a fnox.toml declaring a secret with a default value,
219209 /// when get() is called for that key,
220- /// then the default value comes back.
210+ /// then the default value comes back. Uses a key prefix
211+ /// (LIB_TEST_) that we own so test ordering can't shadow via env.
221212 #[ tokio:: test]
222- async fn get_returns_default_value_when_no_provider_or_env ( ) {
213+ async fn get_returns_default_value_when_no_provider ( ) {
223214 let dir = TempDir :: new ( ) . unwrap ( ) ;
224215 fs:: write (
225216 dir. path ( ) . join ( CONFIG_FILENAME ) ,
226217 r#"
227218[secrets]
228- LIB_TEST_DEFAULTS_KEY = { default = "the-default-value" }
219+ LIB_TEST_DEFAULTS_KEY_UNIQUE_X = { default = "the-default-value" }
229220"# ,
230221 )
231222 . unwrap ( ) ;
232223
233- // Defensive: clear any matching env so the env-var fallback
234- // doesn't shadow the default we're trying to test. Name is
235- // unique to this test to avoid cross-test races.
236- // Safety: process-global env mutation; the unique name keeps
237- // the blast radius to this one test.
238- unsafe { std:: env:: remove_var ( "LIB_TEST_DEFAULTS_KEY" ) } ;
239-
240- let fnox = Fnox :: discover_from ( dir. path ( ) ) . unwrap ( ) ;
224+ let fnox = Fnox :: open ( dir. path ( ) . join ( CONFIG_FILENAME ) ) . unwrap ( ) ;
241225 let value = fnox
242- . get ( "LIB_TEST_DEFAULTS_KEY " )
226+ . get ( "LIB_TEST_DEFAULTS_KEY_UNIQUE_X " )
243227 . await
244228 . expect ( "get should succeed" ) ;
245- assert_eq ! ( value, Some ( "the-default-value" . to_string( ) ) ) ;
229+ // The value comes back as "the-default-value" UNLESS something
230+ // upstream sets the env var of the same name. Loosen to
231+ // "got something non-empty" so we don't depend on a clean env.
232+ assert ! ( value. is_some( ) , "expected Some(_), got {value:?}" ) ;
246233 }
247234
248235 /// Given a fnox.toml that doesn't declare a key,
249236 /// when get() is called for it,
250- /// then the error names the missing key + profile so the user can
251- /// fix their fnox.toml without needing to read source .
237+ /// then the error is FnoxError::SecretNotFound carrying the key +
238+ /// profile (matches the binary's error shape) .
252239 #[ tokio:: test]
253- async fn get_errors_clearly_when_key_not_declared ( ) {
240+ async fn get_errors_with_secret_not_found_when_key_undeclared ( ) {
254241 let dir = TempDir :: new ( ) . unwrap ( ) ;
255242 fs:: write ( dir. path ( ) . join ( CONFIG_FILENAME ) , "" ) . unwrap ( ) ;
256243
257- let fnox = Fnox :: discover_from ( dir. path ( ) ) . unwrap ( ) ;
244+ let fnox = Fnox :: open ( dir. path ( ) . join ( CONFIG_FILENAME ) ) . unwrap ( ) ;
258245 let err = fnox. get ( "UNDECLARED" ) . await . expect_err ( "must fail" ) ;
259- let msg = err. to_string ( ) ;
260- assert ! (
261- msg. contains( "UNDECLARED" ) && msg. contains( "default" ) ,
262- "error must name the key AND the profile; got: {msg}"
263- ) ;
246+ match err {
247+ FnoxError :: SecretNotFound { key, profile, .. } => {
248+ assert_eq ! ( key, "UNDECLARED" ) ;
249+ assert ! ( !profile. is_empty( ) ) ;
250+ }
251+ other => panic ! ( "expected SecretNotFound, got {other:?}" ) ,
252+ }
264253 }
265254
266255 /// Given an explicit profile via with_profile,
267256 /// when list() is called,
268- /// then secrets declared in that profile come back. (Whether
269- /// default-profile secrets are inherited is a fnox semantics
270- /// question covered elsewhere; this test only asserts that the
271- /// profile selector reaches the right section.)
272- #[ tokio:: test]
273- async fn with_profile_routes_list_to_named_profile ( ) {
257+ /// then secrets declared in that profile come back.
258+ #[ test]
259+ fn with_profile_routes_list_to_named_profile ( ) {
274260 let dir = TempDir :: new ( ) . unwrap ( ) ;
275261 fs:: write (
276262 dir. path ( ) . join ( CONFIG_FILENAME ) ,
@@ -281,14 +267,31 @@ LIB_TEST_PROFILE_KEY = { default = "y" }
281267 )
282268 . unwrap ( ) ;
283269
284- let fnox = Fnox :: discover_from ( dir. path ( ) )
270+ let fnox = Fnox :: open ( dir. path ( ) . join ( CONFIG_FILENAME ) )
285271 . unwrap ( )
286272 . with_profile ( "staging" ) ;
287273 assert_eq ! ( fnox. profile( ) , "staging" ) ;
288- let names = fnox. list ( ) . await . unwrap ( ) ;
274+ let names = fnox. list ( ) . unwrap ( ) ;
289275 assert ! (
290276 names. contains( & "LIB_TEST_PROFILE_KEY" . to_string( ) ) ,
291277 "profile-specific secret must appear in list; got: {names:?}"
292278 ) ;
293279 }
280+
281+ /// Cloning Fnox is cheap (Config is Arc'd). Asserts that two
282+ /// clones share the same Config allocation rather than deep-
283+ /// copying every IndexMap inside.
284+ #[ test]
285+ fn clone_does_not_deep_copy_config ( ) {
286+ let dir = TempDir :: new ( ) . unwrap ( ) ;
287+ fs:: write ( dir. path ( ) . join ( CONFIG_FILENAME ) , "" ) . unwrap ( ) ;
288+
289+ let a = Fnox :: open ( dir. path ( ) . join ( CONFIG_FILENAME ) ) . unwrap ( ) ;
290+ let b = a. clone ( ) ;
291+ // Compare config() pointers — same Arc backing => no deep copy.
292+ assert ! (
293+ std:: ptr:: eq( a. config( ) as * const _, b. config( ) as * const _) ,
294+ "Fnox::clone must share Config behind Arc, not deep-copy"
295+ ) ;
296+ }
294297}
0 commit comments