use std::collections::{BTreeSet, HashMap};
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::utils::env::{EnvSource, SystemEnv};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EnvValueSource {
CliFlag,
ProcessEnv,
SettingsEnv,
SettingsProfile(String),
}
impl fmt::Display for EnvValueSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CliFlag => write!(f, "command-line flag"),
Self::ProcessEnv => write!(f, "process environment variable (e.g. a shell export)"),
Self::SettingsEnv => write!(f, "the env map in $HOME/.omni-dev/settings.json"),
Self::SettingsProfile(name) => {
write!(
f,
"the profile '{name}' env map in $HOME/.omni-dev/settings.json"
)
}
}
}
}
static CLI_FLAG_EXPORTS: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
pub fn note_cli_flag_export(key: &str) {
let mut set = CLI_FLAG_EXPORTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
set.insert(key.to_string());
}
#[must_use]
pub fn exported_by_cli_flag(key: &str) -> bool {
CLI_FLAG_EXPORTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(key)
}
pub const PROFILE_ENV_VAR: &str = "OMNI_DEV_PROFILE";
#[derive(Debug, Default, Deserialize)]
pub struct Profile {
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct Settings {
#[serde(default)]
pub env: HashMap<String, String>,
#[serde(default)]
pub profiles: HashMap<String, Profile>,
}
pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
}
#[must_use]
pub fn profile_suffix(profile: Option<&str>) -> String {
profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
}
#[derive(Debug, Default)]
pub struct SettingsEnv {
settings: Settings,
active_profile: Option<String>,
}
impl SettingsEnv {
pub fn load() -> Self {
Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
}
pub fn load_with_profile(profile: Option<&str>) -> Self {
Self {
settings: Settings::load().unwrap_or_default(),
active_profile: profile.map(str::to_string),
}
}
}
impl EnvSource for SettingsEnv {
fn var(&self, key: &str) -> Option<String> {
self.settings
.resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
}
}
impl Settings {
pub fn load() -> Result<Self> {
let settings_path = Self::get_settings_path()?;
Self::load_from_path(&settings_path)
}
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
if !path.exists() {
return Ok(Self::default());
}
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read settings file: {}", path.display()))?;
serde_json::from_str::<Self>(&content)
.with_context(|| format!("Failed to parse settings file: {}", path.display()))
}
pub fn get_settings_path() -> Result<PathBuf> {
let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
Ok(home_dir.join(".omni-dev").join("settings.json"))
}
pub fn get_env_var(&self, key: &str) -> Option<String> {
self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
}
pub fn resolve_with<E: EnvSource>(
&self,
raw: &E,
active: Option<&str>,
key: &str,
) -> Option<String> {
self.resolve_with_source(raw, active, key)
.map(|(value, _)| value)
}
pub fn resolve_with_source<E: EnvSource>(
&self,
raw: &E,
active: Option<&str>,
key: &str,
) -> Option<(String, EnvValueSource)> {
if let Some(value) = raw.var(key) {
return Some((value, EnvValueSource::ProcessEnv));
}
match active {
Some(name) => self
.profiles
.get(name)
.and_then(|p| p.env.get(key).cloned())
.map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
None => self
.env
.get(key)
.cloned()
.map(|value| (value, EnvValueSource::SettingsEnv)),
}
}
pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
Self::upsert_env_vars_in(path, None, vars)
}
pub fn upsert_env_vars_in(
path: &Path,
profile: Option<&str>,
vars: &[(&str, &str)],
) -> Result<()> {
let mut settings_value = read_or_default_settings(path)?;
let env = ensure_env_object(&mut settings_value, profile)?;
for (key, value) in vars {
env.insert(
(*key).to_string(),
serde_json::Value::String((*value).to_string()),
);
}
write_settings(path, &settings_value)
}
pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
Self::remove_env_vars_in(path, None, keys)
}
pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
if !path.exists() {
return Ok(false);
}
let mut settings_value = read_or_default_settings(path)?;
let mut removed = false;
if let Some(env) = env_object_mut(&mut settings_value, profile) {
for key in keys {
if env.remove(*key).is_some() {
removed = true;
}
}
}
if removed {
write_settings(path, &settings_value)?;
}
Ok(removed)
}
pub fn validate_profile(&self, name: &str) -> Result<()> {
if self.profiles.contains_key(name) {
return Ok(());
}
let known = if self.profiles.is_empty() {
"(none)".to_string()
} else {
let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
names.sort_unstable();
names.join(", ")
};
Err(anyhow::anyhow!(
"unknown profile '{name}'; known profiles: {known}"
))
}
}
fn ensure_env_object<'a>(
root: &'a mut serde_json::Value,
profile: Option<&str>,
) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
let parent = match profile {
Some(name) => {
if !root
.get("profiles")
.is_some_and(serde_json::Value::is_object)
{
root["profiles"] = serde_json::json!({});
}
let profiles = &mut root["profiles"];
if !profiles.get(name).is_some_and(serde_json::Value::is_object) {
profiles[name] = serde_json::json!({});
}
&mut profiles[name]
}
None => root,
};
if !parent.get("env").is_some_and(serde_json::Value::is_object) {
parent["env"] = serde_json::json!({});
}
parent["env"]
.as_object_mut()
.context("Internal error: env key is not an object after initialization")
}
fn env_object_mut<'a>(
root: &'a mut serde_json::Value,
profile: Option<&str>,
) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
let parent = match profile {
Some(name) => root.get_mut("profiles")?.get_mut(name)?,
None => root,
};
parent
.get_mut("env")
.and_then(serde_json::Value::as_object_mut)
}
fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
if path.exists() {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read {}", path.display()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse {}", path.display()))
} else {
Ok(serde_json::json!({}))
}
}
fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
crate::daemon::paths::ensure_dir_0700(parent)?;
}
}
let formatted =
serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
write_file_0600(path, &formatted)
.with_context(|| format!("Failed to write {}", path.display()))?;
crate::daemon::paths::set_file_0600(path)?;
Ok(())
}
#[cfg(unix)]
fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
file.write_all(contents.as_bytes())
}
#[cfg(not(unix))]
fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
fs::write(path, contents)
}
pub fn get_env_var(key: &str) -> Result<String> {
get_env_var_with(&SystemEnv, Settings::load, key)
}
pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
}
fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
where
E: EnvSource,
F: FnOnce() -> Result<Settings>,
{
get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
}
fn get_env_var_sourced_with<E, F>(
env: &E,
load: F,
from_cli_flag: bool,
key: &str,
) -> Result<(String, EnvValueSource)>
where
E: EnvSource,
F: FnOnce() -> Result<Settings>,
{
if let Some(value) = env.var(key) {
let source = if from_cli_flag {
EnvValueSource::CliFlag
} else {
EnvValueSource::ProcessEnv
};
return Ok((value, source));
}
match load() {
Ok(settings) => settings
.resolve_with_source(env, active_profile_from(env).as_deref(), key)
.ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
Err(err) => {
Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
}
}
}
pub fn get_env_vars(keys: &[&str]) -> Result<String> {
for key in keys {
if let Ok(value) = get_env_var(key) {
return Ok(value);
}
}
Err(anyhow::anyhow!(
"None of the environment variables found: {keys:?}"
))
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::test_support::env::MapEnv;
use std::env;
use std::fs;
use tempfile::TempDir;
fn settings_with_profile() -> Settings {
let mut base = HashMap::new();
base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
base.insert("SHARED".to_string(), "base-shared".to_string());
let mut work_env = HashMap::new();
work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
let mut profiles = HashMap::new();
profiles.insert("work".to_string(), Profile { env: work_env });
Settings {
env: base,
profiles,
}
}
#[test]
fn settings_load_from_path() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join("settings.json");
let settings_json = r#"{
"env": {
"TEST_VAR": "test_value",
"CLAUDE_API_KEY": "test_api_key"
}
}"#;
fs::write(&settings_path, settings_json).unwrap();
let settings = Settings::load_from_path(&settings_path).unwrap();
assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
}
#[test]
fn settings_get_env_var() {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
TempDir::new_in("tmp").unwrap()
};
let settings_path = temp_dir.path().join("settings.json");
let settings_json = r#"{
"env": {
"TEST_VAR": "test_value",
"CLAUDE_API_KEY": "test_api_key"
}
}"#;
fs::write(&settings_path, settings_json).unwrap();
let settings = Settings::load_from_path(&settings_path).unwrap();
env::set_var("TEST_VAR_ENV", "env_value");
env::set_var("TEST_VAR", "env_override");
assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
env::remove_var("TEST_VAR_ENV");
}
#[test]
fn resolve_no_profile_uses_base_env() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(
settings
.resolve_with(&raw, None, "ATLASSIAN_EMAIL")
.as_deref(),
Some("base@x.com")
);
}
#[test]
fn resolve_active_profile_uses_profile_env() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(
settings
.resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
.as_deref(),
Some("me@work.com")
);
}
#[test]
fn resolve_active_profile_does_not_consult_base() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
}
#[test]
fn resolve_process_env_wins_over_profile_and_base() {
let settings = settings_with_profile();
let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
assert_eq!(
settings
.resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
.as_deref(),
Some("cli@x.com")
);
assert_eq!(
settings
.resolve_with(&raw, None, "ATLASSIAN_EMAIL")
.as_deref(),
Some("cli@x.com")
);
}
#[test]
fn resolve_unknown_active_profile_yields_none() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(
settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
None
);
}
#[test]
fn resolve_with_source_process_env_is_process_env() {
let settings = settings_with_profile();
let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
assert_eq!(
settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
);
}
#[test]
fn resolve_with_source_base_env_is_settings_env() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(
settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
);
}
#[test]
fn resolve_with_source_profile_env_names_profile() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(
settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
Some((
"me@work.com".to_string(),
EnvValueSource::SettingsProfile("work".to_string())
))
);
}
#[test]
fn resolve_with_source_missing_key_is_none() {
let settings = settings_with_profile();
let raw = MapEnv::new();
assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
}
#[test]
fn env_value_source_display_names_each_layer() {
assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
assert_eq!(
EnvValueSource::ProcessEnv.to_string(),
"process environment variable (e.g. a shell export)"
);
assert_eq!(
EnvValueSource::SettingsEnv.to_string(),
"the env map in $HOME/.omni-dev/settings.json"
);
assert_eq!(
EnvValueSource::SettingsProfile("work".to_string()).to_string(),
"the profile 'work' env map in $HOME/.omni-dev/settings.json"
);
}
#[test]
fn active_profile_from_reads_and_trims_empty() {
assert_eq!(active_profile_from(&MapEnv::new()), None);
assert_eq!(
active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
None
);
assert_eq!(
active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
Some("work")
);
}
#[test]
fn profile_suffix_names_profile_or_is_empty() {
assert_eq!(profile_suffix(None), "");
assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
}
#[test]
fn validate_profile_accepts_known() {
assert!(settings_with_profile().validate_profile("work").is_ok());
}
#[test]
fn validate_profile_rejects_unknown_and_lists_sorted() {
let mut settings = settings_with_profile();
settings
.profiles
.insert("personal".to_string(), Profile::default());
let err = settings.validate_profile("wrok").unwrap_err().to_string();
assert_eq!(
err,
"unknown profile 'wrok'; known profiles: personal, work"
);
}
#[test]
fn validate_profile_reports_none_when_empty() {
let settings = Settings::default();
let err = settings.validate_profile("work").unwrap_err().to_string();
assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
}
#[test]
fn settings_parse_profiles_from_json() {
let json = r#"{
"env": { "BASE": "b" },
"profiles": {
"work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
}
}"#;
let settings: Settings = serde_json::from_str(json).unwrap();
assert_eq!(settings.env.get("BASE").unwrap(), "b");
assert_eq!(
settings
.profiles
.get("work")
.unwrap()
.env
.get("ATLASSIAN_EMAIL")
.unwrap(),
"me@work.com"
);
}
#[test]
fn settings_without_profiles_key_defaults_empty() {
let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
assert!(settings.profiles.is_empty());
}
#[test]
fn get_env_var_with_returns_raw_hit_without_loading() {
let env = MapEnv::new().with("K", "v");
let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
assert_eq!(value, "v");
}
#[test]
fn get_env_var_with_falls_back_to_base_settings() {
let settings = settings_with_profile();
let env = MapEnv::new();
let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
assert_eq!(value, "base@x.com");
}
#[test]
fn get_env_var_with_honours_active_profile() {
let settings = settings_with_profile();
let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
assert_eq!(value, "me@work.com");
}
#[test]
fn get_env_var_with_missing_key_is_not_found() {
let env = MapEnv::new();
let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
.unwrap_err()
.to_string();
assert!(err.contains("Environment variable not found: MISSING"));
}
#[test]
fn get_env_var_with_load_error_maps_to_not_found() {
let env = MapEnv::new();
let err =
get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
assert_eq!(err.to_string(), "disk boom");
let chain = format!("{err:#}");
assert!(chain.contains("Environment variable not found: MISSING"));
}
#[test]
fn get_env_var_sourced_with_raw_hit_is_process_env() {
let env = MapEnv::new().with("K", "v");
let resolved =
get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
.unwrap();
assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
}
#[test]
fn get_env_var_sourced_with_flag_export_is_cli_flag() {
let env = MapEnv::new().with("K", "true");
let resolved =
get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
}
#[test]
fn get_env_var_sourced_with_falls_back_to_settings_sources() {
let settings = settings_with_profile();
let env = MapEnv::new();
let resolved =
get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
assert_eq!(
resolved,
("base@x.com".to_string(), EnvValueSource::SettingsEnv)
);
let settings = settings_with_profile();
let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
let resolved =
get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
assert_eq!(
resolved,
(
"me@work.com".to_string(),
EnvValueSource::SettingsProfile("work".to_string())
)
);
}
#[test]
fn cli_flag_export_registry_roundtrip() {
const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
assert!(!exported_by_cli_flag(KEY));
note_cli_flag_export(KEY);
assert!(exported_by_cli_flag(KEY));
}
fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
let temp_dir = {
std::fs::create_dir_all("tmp").ok();
TempDir::new_in("tmp").unwrap()
};
let path = temp_dir.path().join(".omni-dev").join("settings.json");
(temp_dir, path)
}
fn read_json(path: &Path) -> serde_json::Value {
serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
}
#[test]
fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
let (_tmp, path) = temp_settings_path();
Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
let val = read_json(&path);
assert_eq!(val["env"]["A_KEY"], "a");
assert_eq!(val["env"]["B_KEY"], "b");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir_mode = fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode();
assert_eq!(dir_mode & 0o777, 0o700);
let file_mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(file_mode & 0o777, 0o600);
}
}
#[test]
fn upsert_env_vars_merges_and_preserves_unknown_fields() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
let val = read_json(&path);
assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
assert_eq!(val["extra"], true);
assert_eq!(val["env"]["A_KEY"], "new");
}
#[test]
fn upsert_env_vars_replaces_non_object_env() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
}
#[cfg(unix)]
#[test]
fn upsert_env_vars_retightens_loose_permissions() {
use std::os::unix::fs::PermissionsExt;
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"env": {}}"#).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
let file_mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(file_mode & 0o777, 0o600);
}
#[test]
fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
)
.unwrap();
let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
assert!(removed);
let val = read_json(&path);
assert!(val["env"].get("A_KEY").is_none());
assert!(val["env"].get("B_KEY").is_none());
assert_eq!(val["env"]["OTHER_KEY"], "keep");
assert_eq!(val["extra"], true);
}
#[test]
fn remove_env_vars_false_when_file_missing() {
let (_tmp, path) = temp_settings_path();
assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
assert!(!path.exists());
}
#[test]
fn remove_env_vars_false_when_env_missing_or_not_an_object() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"extra": true}"#).unwrap();
assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
}
#[test]
fn upsert_env_vars_bare_filename_skips_dir_creation() {
let name = format!("tmp-upsert-bare-{}.json", std::process::id());
let path = Path::new(&name);
Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
assert_eq!(read_json(path)["env"]["A_KEY"], "a");
fs::remove_file(path).unwrap();
}
#[test]
fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
fs::write(&path, original).unwrap();
let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
assert!(!removed);
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn upsert_env_vars_in_profile_creates_profile_env() {
let (_tmp, path) = temp_settings_path();
Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
let val = read_json(&path);
assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
assert!(val.get("env").is_none());
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let dir_mode = fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode();
assert_eq!(dir_mode & 0o777, 0o700);
let file_mode = fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(file_mode & 0o777, 0o600);
}
}
#[test]
fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{
"env": {"SHARED": "base"},
"profiles": {
"work": {"env": {"OLD": "keep"}},
"home": {"env": {"SHARED": "home"}}
},
"extra": true
}"#,
)
.unwrap();
Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
let val = read_json(&path);
assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
assert_eq!(val["env"]["SHARED"], "base");
assert_eq!(val["extra"], true);
}
#[test]
fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
}
#[test]
fn remove_env_vars_in_profile_removes_only_profile_keys() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{
"env": {"A_KEY": "base"},
"profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
}"#,
)
.unwrap();
let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
assert!(removed);
let val = read_json(&path);
assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
assert_eq!(val["env"]["A_KEY"], "base");
}
#[test]
fn remove_env_vars_in_profile_false_when_profile_missing() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
let original = r#"{"env": {"A_KEY": "base"}}"#;
fs::write(&path, original).unwrap();
let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
assert!(!removed);
assert_eq!(fs::read_to_string(&path).unwrap(), original);
}
#[test]
fn remove_env_vars_in_none_targets_base_env() {
let (_tmp, path) = temp_settings_path();
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(
&path,
r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
)
.unwrap();
let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
assert!(removed);
let val = read_json(&path);
assert!(val["env"].get("A_KEY").is_none());
assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
}
}