use std::path::Path;
use std::sync::Arc;
use crate::config::Config;
use crate::error::{FnoxError, Result};
pub const CONFIG_FILENAME: &str = "fnox.toml";
#[derive(Debug, Clone)]
pub struct Fnox {
config: Arc<Config>,
profile: String,
}
impl Fnox {
pub fn discover() -> Result<Self> {
let config = Config::load_smart(CONFIG_FILENAME)?;
let profile = Config::get_profile(None);
Ok(Self {
config: Arc::new(config),
profile,
})
}
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path_ref = path.as_ref();
let resolved = if path_ref.is_relative() {
crate::env::current_dir()
.map_err(|e| FnoxError::Config(format!("Failed to read current directory: {e}")))?
.join(path_ref)
} else {
path_ref.to_path_buf()
};
let config = Config::load(resolved)?;
let profile = Config::get_profile(None);
Ok(Self {
config: Arc::new(config),
profile,
})
}
pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
self.profile = profile.into();
self
}
pub fn profile(&self) -> &str {
&self.profile
}
pub fn config(&self) -> &Config {
&self.config
}
pub async fn get(&self, key: &str) -> Result<Option<String>> {
if let Some(secret_config) = self.config.get_secret(&self.profile, key) {
return crate::secret_resolver::resolve_secret(
&self.config,
&self.profile,
key,
secret_config,
)
.await;
}
let suggestion = self.list().ok().and_then(|names| {
let similar = crate::suggest::find_similar(key, names.iter().map(|s| s.as_str()));
crate::suggest::format_suggestions(&similar)
});
Err(FnoxError::SecretNotFound {
key: key.to_string(),
profile: self.profile.clone(),
config_path: self.config.secret_sources.get(key).cloned(),
suggestion,
})
}
pub fn list(&self) -> Result<Vec<String>> {
let secrets = self.config.get_secrets(&self.profile)?;
Ok(secrets.keys().cloned().collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn open_loads_explicit_path() {
let dir = TempDir::new().unwrap();
let path = dir.path().join(CONFIG_FILENAME);
fs::write(&path, "").unwrap();
let fnox = Fnox::open(&path).expect("open should succeed");
assert!(!fnox.profile().is_empty());
}
#[test]
fn open_errors_when_path_missing() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("does-not-exist.toml");
let err = Fnox::open(&missing).expect_err("must fail");
let _ = err.to_string();
}
#[test]
fn open_with_bare_default_filename_does_not_silently_discover() {
let result = Fnox::open(CONFIG_FILENAME);
if let Ok(_fnox) = result {
eprintln!(
"WARNING: Fnox::open(CONFIG_FILENAME) succeeded — likely a fnox.toml \
exists in CWD ({}). Test environment masks the regression we want \
to guard against. Re-run from a directory without fnox.toml.",
std::env::current_dir().unwrap().display()
);
return;
}
assert!(result.is_err());
}
#[test]
fn list_returns_declared_secrets_in_declaration_order() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(CONFIG_FILENAME),
r#"
[secrets]
ZFIRST = { default = "first-default" }
ASECOND = { default = "second-default" }
"#,
)
.unwrap();
let fnox = Fnox::open(dir.path().join(CONFIG_FILENAME)).unwrap();
let names = fnox.list().unwrap();
assert_eq!(
names,
vec!["ZFIRST".to_string(), "ASECOND".to_string()],
"list must preserve declaration order, not sort alphabetically"
);
}
#[tokio::test]
async fn get_returns_default_value_when_no_provider() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(CONFIG_FILENAME),
r#"
[secrets]
LIB_TEST_DEFAULTS_KEY_UNIQUE_X = { default = "the-default-value" }
"#,
)
.unwrap();
let fnox = Fnox::open(dir.path().join(CONFIG_FILENAME)).unwrap();
let value = fnox
.get("LIB_TEST_DEFAULTS_KEY_UNIQUE_X")
.await
.expect("get should succeed");
assert!(value.is_some(), "expected Some(_), got {value:?}");
}
#[tokio::test]
async fn get_errors_with_secret_not_found_when_key_undeclared() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(CONFIG_FILENAME), "").unwrap();
let fnox = Fnox::open(dir.path().join(CONFIG_FILENAME)).unwrap();
let err = fnox.get("UNDECLARED").await.expect_err("must fail");
match err {
FnoxError::SecretNotFound { key, profile, .. } => {
assert_eq!(key, "UNDECLARED");
assert!(!profile.is_empty());
}
other => panic!("expected SecretNotFound, got {other:?}"),
}
}
#[tokio::test]
async fn get_secret_not_found_carries_did_you_mean_suggestion() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(CONFIG_FILENAME),
r#"
[secrets]
DATABASE_URL = { default = "x" }
DATABASE_TOKEN = { default = "y" }
NPM_TOKEN = { default = "z" }
"#,
)
.unwrap();
let fnox = Fnox::open(dir.path().join(CONFIG_FILENAME)).unwrap();
let err = fnox.get("DATABASE_UR").await.expect_err("must fail");
match err {
FnoxError::SecretNotFound { suggestion, .. } => {
let s = suggestion.expect("suggestion should be populated for near-matches");
assert!(
s.contains("DATABASE_URL"),
"suggestion should mention the closest match; got: {s:?}"
);
}
other => panic!("expected SecretNotFound, got {other:?}"),
}
}
#[test]
fn with_profile_routes_list_to_named_profile() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join(CONFIG_FILENAME),
r#"
[profiles.staging.secrets]
LIB_TEST_PROFILE_KEY = { default = "y" }
"#,
)
.unwrap();
let fnox = Fnox::open(dir.path().join(CONFIG_FILENAME))
.unwrap()
.with_profile("staging");
assert_eq!(fnox.profile(), "staging");
let names = fnox.list().unwrap();
assert!(
names.contains(&"LIB_TEST_PROFILE_KEY".to_string()),
"profile-specific secret must appear in list; got: {names:?}"
);
}
#[test]
fn clone_does_not_deep_copy_config() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(CONFIG_FILENAME), "").unwrap();
let a = Fnox::open(dir.path().join(CONFIG_FILENAME)).unwrap();
let b = a.clone();
assert!(
std::ptr::eq(a.config() as *const _, b.config() as *const _),
"Fnox::clone must share Config behind Arc, not deep-copy"
);
}
}