1use std::collections::{BTreeSet, HashMap};
16use std::fmt;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20
21use anyhow::{Context, Result};
22use serde::Deserialize;
23
24use crate::utils::env::{EnvSource, SystemEnv};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum EnvValueSource {
35 CliFlag,
38 ProcessEnv,
40 SettingsEnv,
42 SettingsProfile(String),
44}
45
46impl fmt::Display for EnvValueSource {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 Self::CliFlag => write!(f, "command-line flag"),
50 Self::ProcessEnv => write!(f, "process environment variable (e.g. a shell export)"),
51 Self::SettingsEnv => write!(f, "the env map in $HOME/.omni-dev/settings.json"),
52 Self::SettingsProfile(name) => {
53 write!(
54 f,
55 "the profile '{name}' env map in $HOME/.omni-dev/settings.json"
56 )
57 }
58 }
59 }
60}
61
62static CLI_FLAG_EXPORTS: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
68
69pub fn note_cli_flag_export(key: &str) {
74 let mut set = CLI_FLAG_EXPORTS
77 .lock()
78 .unwrap_or_else(std::sync::PoisonError::into_inner);
79 set.insert(key.to_string());
80}
81
82#[must_use]
84pub fn exported_by_cli_flag(key: &str) -> bool {
85 CLI_FLAG_EXPORTS
86 .lock()
87 .unwrap_or_else(std::sync::PoisonError::into_inner)
88 .contains(key)
89}
90
91pub const PROFILE_ENV_VAR: &str = "OMNI_DEV_PROFILE";
97
98#[derive(Debug, Default, Deserialize)]
101pub struct Profile {
102 #[serde(default)]
104 pub env: HashMap<String, String>,
105}
106
107#[derive(Debug, Default, Deserialize)]
114pub struct McpSettings {
115 #[serde(default)]
118 pub default_model: Option<String>,
119
120 #[serde(default)]
124 pub log_level: Option<String>,
125
126 #[serde(default)]
130 pub max_response_bytes: Option<usize>,
131}
132
133#[derive(Debug, Default, Deserialize)]
135pub struct Settings {
136 #[serde(default)]
139 pub env: HashMap<String, String>,
140
141 #[serde(default)]
144 pub profiles: HashMap<String, Profile>,
145
146 #[serde(default)]
149 pub mcp: McpSettings,
150}
151
152pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
158 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
159}
160
161#[must_use]
165pub fn profile_suffix(profile: Option<&str>) -> String {
166 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
167}
168
169#[derive(Debug, Default)]
180pub struct SettingsEnv {
181 settings: Settings,
182 active_profile: Option<String>,
183}
184
185impl SettingsEnv {
186 pub fn load() -> Self {
190 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
191 }
192
193 pub fn load_with_profile(profile: Option<&str>) -> Self {
197 Self {
198 settings: Settings::load().unwrap_or_default(),
199 active_profile: profile.map(str::to_string),
200 }
201 }
202}
203
204impl EnvSource for SettingsEnv {
205 fn var(&self, key: &str) -> Option<String> {
206 self.settings
207 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
208 }
209}
210
211impl Settings {
212 pub fn load() -> Result<Self> {
214 let settings_path = Self::get_settings_path()?;
215 Self::load_from_path(&settings_path)
216 }
217
218 pub fn load_mcp() -> McpSettings {
223 Self::load().map(|s| s.mcp).unwrap_or_default()
224 }
225
226 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
228 let path = path.as_ref();
229
230 if !path.exists() {
232 return Ok(Self::default());
233 }
234
235 let content = fs::read_to_string(path)
237 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
238
239 serde_json::from_str::<Self>(&content)
240 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
241 }
242
243 pub fn get_settings_path() -> Result<PathBuf> {
245 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
246
247 Ok(home_dir.join(".omni-dev").join("settings.json"))
248 }
249
250 pub fn get_env_var(&self, key: &str) -> Option<String> {
253 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
254 }
255
256 pub fn resolve_with<E: EnvSource>(
265 &self,
266 raw: &E,
267 active: Option<&str>,
268 key: &str,
269 ) -> Option<String> {
270 self.resolve_with_source(raw, active, key)
271 .map(|(value, _)| value)
272 }
273
274 pub fn resolve_with_source<E: EnvSource>(
282 &self,
283 raw: &E,
284 active: Option<&str>,
285 key: &str,
286 ) -> Option<(String, EnvValueSource)> {
287 if let Some(value) = raw.var(key) {
288 return Some((value, EnvValueSource::ProcessEnv));
289 }
290 match active {
291 Some(name) => self
292 .profiles
293 .get(name)
294 .and_then(|p| p.env.get(key).cloned())
295 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
296 None => self
297 .env
298 .get(key)
299 .cloned()
300 .map(|value| (value, EnvValueSource::SettingsEnv)),
301 }
302 }
303
304 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
308 Self::upsert_env_vars_in(path, None, vars)
309 }
310
311 pub fn upsert_env_vars_in(
326 path: &Path,
327 profile: Option<&str>,
328 vars: &[(&str, &str)],
329 ) -> Result<()> {
330 let mut settings_value = read_or_default_settings(path)?;
331
332 let env = ensure_env_object(&mut settings_value, profile)?;
333 for (key, value) in vars {
334 env.insert(
335 (*key).to_string(),
336 serde_json::Value::String((*value).to_string()),
337 );
338 }
339
340 write_settings(path, &settings_value)
341 }
342
343 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
346 Self::remove_env_vars_in(path, None, keys)
347 }
348
349 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
360 if !path.exists() {
361 return Ok(false);
362 }
363 let mut settings_value = read_or_default_settings(path)?;
364
365 let mut removed = false;
366 if let Some(env) = env_object_mut(&mut settings_value, profile) {
367 for key in keys {
368 if env.remove(*key).is_some() {
369 removed = true;
370 }
371 }
372 }
373
374 if removed {
375 write_settings(path, &settings_value)?;
376 }
377 Ok(removed)
378 }
379
380 pub fn validate_profile(&self, name: &str) -> Result<()> {
384 if self.profiles.contains_key(name) {
385 return Ok(());
386 }
387 let known = if self.profiles.is_empty() {
388 "(none)".to_string()
389 } else {
390 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
391 names.sort_unstable();
392 names.join(", ")
393 };
394 Err(anyhow::anyhow!(
395 "unknown profile '{name}'; known profiles: {known}"
396 ))
397 }
398}
399
400fn ensure_env_object<'a>(
405 root: &'a mut serde_json::Value,
406 profile: Option<&str>,
407) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
408 let parent = match profile {
409 Some(name) => {
410 if !root
411 .get("profiles")
412 .is_some_and(serde_json::Value::is_object)
413 {
414 root["profiles"] = serde_json::json!({});
415 }
416 let profiles = &mut root["profiles"];
417 if !profiles.get(name).is_some_and(serde_json::Value::is_object) {
418 profiles[name] = serde_json::json!({});
419 }
420 &mut profiles[name]
421 }
422 None => root,
423 };
424
425 if !parent.get("env").is_some_and(serde_json::Value::is_object) {
426 parent["env"] = serde_json::json!({});
427 }
428 parent["env"]
429 .as_object_mut()
430 .context("Internal error: env key is not an object after initialization")
431}
432
433fn env_object_mut<'a>(
437 root: &'a mut serde_json::Value,
438 profile: Option<&str>,
439) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
440 let parent = match profile {
441 Some(name) => root.get_mut("profiles")?.get_mut(name)?,
442 None => root,
443 };
444 parent
445 .get_mut("env")
446 .and_then(serde_json::Value::as_object_mut)
447}
448
449fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
452 if path.exists() {
453 let content = fs::read_to_string(path)
454 .with_context(|| format!("Failed to read {}", path.display()))?;
455 serde_json::from_str(&content)
456 .with_context(|| format!("Failed to parse {}", path.display()))
457 } else {
458 Ok(serde_json::json!({}))
459 }
460}
461
462fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
467 if let Some(parent) = path.parent() {
468 if !parent.as_os_str().is_empty() {
469 crate::daemon::paths::ensure_dir_0700(parent)?;
470 }
471 }
472 let formatted =
473 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
474 write_file_0600(path, &formatted)
475 .with_context(|| format!("Failed to write {}", path.display()))?;
476 crate::daemon::paths::set_file_0600(path)?;
477 Ok(())
478}
479
480#[cfg(unix)]
482fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
483 use std::io::Write;
484 use std::os::unix::fs::OpenOptionsExt;
485
486 let mut file = fs::OpenOptions::new()
487 .write(true)
488 .create(true)
489 .truncate(true)
490 .mode(0o600)
491 .open(path)?;
492 file.write_all(contents.as_bytes())
493}
494
495#[cfg(not(unix))]
498fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
499 fs::write(path, contents)
500}
501
502pub fn get_env_var(key: &str) -> Result<String> {
505 get_env_var_with(&SystemEnv, Settings::load, key)
506}
507
508pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
515 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
516}
517
518fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
521where
522 E: EnvSource,
523 F: FnOnce() -> Result<Settings>,
524{
525 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
526}
527
528fn get_env_var_sourced_with<E, F>(
536 env: &E,
537 load: F,
538 from_cli_flag: bool,
539 key: &str,
540) -> Result<(String, EnvValueSource)>
541where
542 E: EnvSource,
543 F: FnOnce() -> Result<Settings>,
544{
545 if let Some(value) = env.var(key) {
549 let source = if from_cli_flag {
550 EnvValueSource::CliFlag
551 } else {
552 EnvValueSource::ProcessEnv
553 };
554 return Ok((value, source));
555 }
556 match load() {
557 Ok(settings) => settings
558 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
559 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
560 Err(err) => {
561 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
563 }
564 }
565}
566
567pub fn get_env_vars(keys: &[&str]) -> Result<String> {
569 for key in keys {
570 if let Ok(value) = get_env_var(key) {
571 return Ok(value);
572 }
573 }
574
575 Err(anyhow::anyhow!(
576 "None of the environment variables found: {keys:?}"
577 ))
578}
579
580#[cfg(test)]
581#[allow(clippy::unwrap_used, clippy::expect_used)]
582mod tests {
583 use super::*;
584 use crate::test_support::env::MapEnv;
585 use std::env;
586 use std::fs;
587 use tempfile::TempDir;
588
589 fn settings_with_profile() -> Settings {
592 let mut base = HashMap::new();
593 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
594 base.insert("SHARED".to_string(), "base-shared".to_string());
595
596 let mut work_env = HashMap::new();
597 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
598
599 let mut profiles = HashMap::new();
600 profiles.insert("work".to_string(), Profile { env: work_env });
601
602 Settings {
603 env: base,
604 profiles,
605 ..Settings::default()
606 }
607 }
608
609 #[test]
610 fn settings_load_from_path() {
611 let temp_dir = {
613 std::fs::create_dir_all("tmp").ok();
614 TempDir::new_in("tmp").unwrap()
615 };
616 let settings_path = temp_dir.path().join("settings.json");
617
618 let settings_json = r#"{
620 "env": {
621 "TEST_VAR": "test_value",
622 "CLAUDE_API_KEY": "test_api_key"
623 }
624 }"#;
625 fs::write(&settings_path, settings_json).unwrap();
626
627 let settings = Settings::load_from_path(&settings_path).unwrap();
629
630 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
632 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
633 }
634
635 #[test]
636 fn settings_get_env_var() {
637 let temp_dir = {
639 std::fs::create_dir_all("tmp").ok();
640 TempDir::new_in("tmp").unwrap()
641 };
642 let settings_path = temp_dir.path().join("settings.json");
643
644 let settings_json = r#"{
646 "env": {
647 "TEST_VAR": "test_value",
648 "CLAUDE_API_KEY": "test_api_key"
649 }
650 }"#;
651 fs::write(&settings_path, settings_json).unwrap();
652
653 let settings = Settings::load_from_path(&settings_path).unwrap();
655
656 env::set_var("TEST_VAR_ENV", "env_value");
658
659 env::set_var("TEST_VAR", "env_override");
661 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
662
663 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
666
667 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
669
670 env::remove_var("TEST_VAR_ENV");
672 }
673
674 #[test]
677 fn resolve_no_profile_uses_base_env() {
678 let settings = settings_with_profile();
679 let raw = MapEnv::new();
680 assert_eq!(
681 settings
682 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
683 .as_deref(),
684 Some("base@x.com")
685 );
686 }
687
688 #[test]
689 fn resolve_active_profile_uses_profile_env() {
690 let settings = settings_with_profile();
691 let raw = MapEnv::new();
692 assert_eq!(
693 settings
694 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
695 .as_deref(),
696 Some("me@work.com")
697 );
698 }
699
700 #[test]
701 fn resolve_active_profile_does_not_consult_base() {
702 let settings = settings_with_profile();
705 let raw = MapEnv::new();
706 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
707 }
708
709 #[test]
710 fn resolve_process_env_wins_over_profile_and_base() {
711 let settings = settings_with_profile();
712 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
713 assert_eq!(
714 settings
715 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
716 .as_deref(),
717 Some("cli@x.com")
718 );
719 assert_eq!(
720 settings
721 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
722 .as_deref(),
723 Some("cli@x.com")
724 );
725 }
726
727 #[test]
728 fn resolve_unknown_active_profile_yields_none() {
729 let settings = settings_with_profile();
732 let raw = MapEnv::new();
733 assert_eq!(
734 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
735 None
736 );
737 }
738
739 #[test]
742 fn resolve_with_source_process_env_is_process_env() {
743 let settings = settings_with_profile();
744 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
745 assert_eq!(
746 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
747 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
748 );
749 }
750
751 #[test]
752 fn resolve_with_source_base_env_is_settings_env() {
753 let settings = settings_with_profile();
754 let raw = MapEnv::new();
755 assert_eq!(
756 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
757 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
758 );
759 }
760
761 #[test]
762 fn resolve_with_source_profile_env_names_profile() {
763 let settings = settings_with_profile();
764 let raw = MapEnv::new();
765 assert_eq!(
766 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
767 Some((
768 "me@work.com".to_string(),
769 EnvValueSource::SettingsProfile("work".to_string())
770 ))
771 );
772 }
773
774 #[test]
775 fn resolve_with_source_missing_key_is_none() {
776 let settings = settings_with_profile();
777 let raw = MapEnv::new();
778 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
779 }
780
781 #[test]
782 fn env_value_source_display_names_each_layer() {
783 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
784 assert_eq!(
785 EnvValueSource::ProcessEnv.to_string(),
786 "process environment variable (e.g. a shell export)"
787 );
788 assert_eq!(
789 EnvValueSource::SettingsEnv.to_string(),
790 "the env map in $HOME/.omni-dev/settings.json"
791 );
792 assert_eq!(
793 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
794 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
795 );
796 }
797
798 #[test]
799 fn active_profile_from_reads_and_trims_empty() {
800 assert_eq!(active_profile_from(&MapEnv::new()), None);
801 assert_eq!(
802 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
803 None
804 );
805 assert_eq!(
806 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
807 Some("work")
808 );
809 }
810
811 #[test]
812 fn profile_suffix_names_profile_or_is_empty() {
813 assert_eq!(profile_suffix(None), "");
814 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
815 }
816
817 #[test]
818 fn validate_profile_accepts_known() {
819 assert!(settings_with_profile().validate_profile("work").is_ok());
820 }
821
822 #[test]
823 fn validate_profile_rejects_unknown_and_lists_sorted() {
824 let mut settings = settings_with_profile();
825 settings
826 .profiles
827 .insert("personal".to_string(), Profile::default());
828 let err = settings.validate_profile("wrok").unwrap_err().to_string();
829 assert_eq!(
830 err,
831 "unknown profile 'wrok'; known profiles: personal, work"
832 );
833 }
834
835 #[test]
836 fn validate_profile_reports_none_when_empty() {
837 let settings = Settings::default();
838 let err = settings.validate_profile("work").unwrap_err().to_string();
839 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
840 }
841
842 #[test]
843 fn settings_parse_profiles_from_json() {
844 let json = r#"{
845 "env": { "BASE": "b" },
846 "profiles": {
847 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
848 }
849 }"#;
850 let settings: Settings = serde_json::from_str(json).unwrap();
851 assert_eq!(settings.env.get("BASE").unwrap(), "b");
852 assert_eq!(
853 settings
854 .profiles
855 .get("work")
856 .unwrap()
857 .env
858 .get("ATLASSIAN_EMAIL")
859 .unwrap(),
860 "me@work.com"
861 );
862 }
863
864 #[test]
865 fn settings_without_profiles_key_defaults_empty() {
866 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
867 assert!(settings.profiles.is_empty());
868 }
869
870 #[test]
871 fn settings_parse_mcp_section_from_json() {
872 let json = r#"{
873 "mcp": {
874 "default_model": "claude-sonnet-4-6",
875 "log_level": "info",
876 "max_response_bytes": 204800
877 }
878 }"#;
879 let settings: Settings = serde_json::from_str(json).unwrap();
880 assert_eq!(
881 settings.mcp.default_model.as_deref(),
882 Some("claude-sonnet-4-6")
883 );
884 assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
885 assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
886 }
887
888 #[test]
889 fn settings_without_mcp_key_defaults_all_none() {
890 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
893 assert!(settings.mcp.default_model.is_none());
894 assert!(settings.mcp.log_level.is_none());
895 assert!(settings.mcp.max_response_bytes.is_none());
896 }
897
898 #[test]
899 fn settings_mcp_partial_section_leaves_others_none() {
900 let settings: Settings =
902 serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
903 assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
904 assert!(settings.mcp.default_model.is_none());
905 assert!(settings.mcp.max_response_bytes.is_none());
906 }
907
908 #[test]
911 fn get_env_var_with_returns_raw_hit_without_loading() {
912 let env = MapEnv::new().with("K", "v");
913 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
914 assert_eq!(value, "v");
915 }
916
917 #[test]
918 fn get_env_var_with_falls_back_to_base_settings() {
919 let settings = settings_with_profile();
920 let env = MapEnv::new();
921 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
922 assert_eq!(value, "base@x.com");
923 }
924
925 #[test]
926 fn get_env_var_with_honours_active_profile() {
927 let settings = settings_with_profile();
928 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
929 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
930 assert_eq!(value, "me@work.com");
931 }
932
933 #[test]
934 fn get_env_var_with_missing_key_is_not_found() {
935 let env = MapEnv::new();
936 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
937 .unwrap_err()
938 .to_string();
939 assert!(err.contains("Environment variable not found: MISSING"));
940 }
941
942 #[test]
943 fn get_env_var_with_load_error_maps_to_not_found() {
944 let env = MapEnv::new();
945 let err =
946 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
947 assert_eq!(err.to_string(), "disk boom");
950 let chain = format!("{err:#}");
951 assert!(chain.contains("Environment variable not found: MISSING"));
952 }
953
954 #[test]
957 fn get_env_var_sourced_with_raw_hit_is_process_env() {
958 let env = MapEnv::new().with("K", "v");
959 let resolved =
960 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
961 .unwrap();
962 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
963 }
964
965 #[test]
966 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
967 let env = MapEnv::new().with("K", "true");
968 let resolved =
969 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
970 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
971 }
972
973 #[test]
974 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
975 let settings = settings_with_profile();
976 let env = MapEnv::new();
977 let resolved =
978 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
979 assert_eq!(
980 resolved,
981 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
982 );
983
984 let settings = settings_with_profile();
985 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
986 let resolved =
987 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
988 assert_eq!(
989 resolved,
990 (
991 "me@work.com".to_string(),
992 EnvValueSource::SettingsProfile("work".to_string())
993 )
994 );
995 }
996
997 #[test]
998 fn cli_flag_export_registry_roundtrip() {
999 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
1002 assert!(!exported_by_cli_flag(KEY));
1003 note_cli_flag_export(KEY);
1004 assert!(exported_by_cli_flag(KEY));
1005 }
1006
1007 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
1012 let temp_dir = {
1013 std::fs::create_dir_all("tmp").ok();
1014 TempDir::new_in("tmp").unwrap()
1015 };
1016 let path = temp_dir.path().join(".omni-dev").join("settings.json");
1017 (temp_dir, path)
1018 }
1019
1020 fn read_json(path: &Path) -> serde_json::Value {
1021 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
1022 }
1023
1024 #[test]
1025 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
1026 let (_tmp, path) = temp_settings_path();
1027
1028 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
1029
1030 let val = read_json(&path);
1031 assert_eq!(val["env"]["A_KEY"], "a");
1032 assert_eq!(val["env"]["B_KEY"], "b");
1033
1034 #[cfg(unix)]
1036 {
1037 use std::os::unix::fs::PermissionsExt;
1038 let dir_mode = fs::metadata(path.parent().unwrap())
1039 .unwrap()
1040 .permissions()
1041 .mode();
1042 assert_eq!(dir_mode & 0o777, 0o700);
1043 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1044 assert_eq!(file_mode & 0o777, 0o600);
1045 }
1046 }
1047
1048 #[test]
1049 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
1050 let (_tmp, path) = temp_settings_path();
1051 fs::create_dir_all(path.parent().unwrap()).unwrap();
1052 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
1053
1054 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
1055
1056 let val = read_json(&path);
1057 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
1058 assert_eq!(val["extra"], true);
1059 assert_eq!(val["env"]["A_KEY"], "new");
1060 }
1061
1062 #[test]
1063 fn upsert_env_vars_replaces_non_object_env() {
1064 let (_tmp, path) = temp_settings_path();
1065 fs::create_dir_all(path.parent().unwrap()).unwrap();
1066 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1067
1068 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1069
1070 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
1071 }
1072
1073 #[cfg(unix)]
1074 #[test]
1075 fn upsert_env_vars_retightens_loose_permissions() {
1076 use std::os::unix::fs::PermissionsExt;
1077
1078 let (_tmp, path) = temp_settings_path();
1079 fs::create_dir_all(path.parent().unwrap()).unwrap();
1080 fs::write(&path, r#"{"env": {}}"#).unwrap();
1081 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1082
1083 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1084
1085 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1086 assert_eq!(file_mode & 0o777, 0o600);
1087 }
1088
1089 #[test]
1090 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1091 let (_tmp, path) = temp_settings_path();
1092 fs::create_dir_all(path.parent().unwrap()).unwrap();
1093 fs::write(
1094 &path,
1095 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1096 )
1097 .unwrap();
1098
1099 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1100 assert!(removed);
1101
1102 let val = read_json(&path);
1103 assert!(val["env"].get("A_KEY").is_none());
1104 assert!(val["env"].get("B_KEY").is_none());
1105 assert_eq!(val["env"]["OTHER_KEY"], "keep");
1106 assert_eq!(val["extra"], true);
1107 }
1108
1109 #[test]
1110 fn remove_env_vars_false_when_file_missing() {
1111 let (_tmp, path) = temp_settings_path();
1112 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1113 assert!(!path.exists());
1114 }
1115
1116 #[test]
1117 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1118 let (_tmp, path) = temp_settings_path();
1119 fs::create_dir_all(path.parent().unwrap()).unwrap();
1120
1121 fs::write(&path, r#"{"extra": true}"#).unwrap();
1123 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1124
1125 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1127 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1128 }
1129
1130 #[test]
1131 fn upsert_env_vars_bare_filename_skips_dir_creation() {
1132 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1135 let path = Path::new(&name);
1136
1137 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1138
1139 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1140 fs::remove_file(path).unwrap();
1141 }
1142
1143 #[test]
1144 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1145 let (_tmp, path) = temp_settings_path();
1146 fs::create_dir_all(path.parent().unwrap()).unwrap();
1147 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1148 fs::write(&path, original).unwrap();
1149
1150 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1151 assert!(!removed);
1152 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1154 }
1155
1156 #[test]
1159 fn upsert_env_vars_in_profile_creates_profile_env() {
1160 let (_tmp, path) = temp_settings_path();
1161
1162 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1163
1164 let val = read_json(&path);
1165 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1166 assert!(val.get("env").is_none());
1168
1169 #[cfg(unix)]
1172 {
1173 use std::os::unix::fs::PermissionsExt;
1174 let dir_mode = fs::metadata(path.parent().unwrap())
1175 .unwrap()
1176 .permissions()
1177 .mode();
1178 assert_eq!(dir_mode & 0o777, 0o700);
1179 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1180 assert_eq!(file_mode & 0o777, 0o600);
1181 }
1182 }
1183
1184 #[test]
1185 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1186 let (_tmp, path) = temp_settings_path();
1187 fs::create_dir_all(path.parent().unwrap()).unwrap();
1188 fs::write(
1189 &path,
1190 r#"{
1191 "env": {"SHARED": "base"},
1192 "profiles": {
1193 "work": {"env": {"OLD": "keep"}},
1194 "home": {"env": {"SHARED": "home"}}
1195 },
1196 "extra": true
1197 }"#,
1198 )
1199 .unwrap();
1200
1201 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1202
1203 let val = read_json(&path);
1204 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1205 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1206 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1207 assert_eq!(val["env"]["SHARED"], "base");
1208 assert_eq!(val["extra"], true);
1209 }
1210
1211 #[test]
1212 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1213 let (_tmp, path) = temp_settings_path();
1214 fs::create_dir_all(path.parent().unwrap()).unwrap();
1215
1216 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1218 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1219 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1220
1221 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1223 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1224 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1225 }
1226
1227 #[test]
1228 fn remove_env_vars_in_profile_removes_only_profile_keys() {
1229 let (_tmp, path) = temp_settings_path();
1230 fs::create_dir_all(path.parent().unwrap()).unwrap();
1231 fs::write(
1232 &path,
1233 r#"{
1234 "env": {"A_KEY": "base"},
1235 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1236 }"#,
1237 )
1238 .unwrap();
1239
1240 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1241 assert!(removed);
1242
1243 let val = read_json(&path);
1244 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1245 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1246 assert_eq!(val["env"]["A_KEY"], "base");
1248 }
1249
1250 #[test]
1251 fn remove_env_vars_in_profile_false_when_profile_missing() {
1252 let (_tmp, path) = temp_settings_path();
1253 fs::create_dir_all(path.parent().unwrap()).unwrap();
1254 let original = r#"{"env": {"A_KEY": "base"}}"#;
1255 fs::write(&path, original).unwrap();
1256
1257 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1258 assert!(!removed);
1259 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1261 }
1262
1263 #[test]
1264 fn remove_env_vars_in_none_targets_base_env() {
1265 let (_tmp, path) = temp_settings_path();
1266 fs::create_dir_all(path.parent().unwrap()).unwrap();
1267 fs::write(
1268 &path,
1269 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1270 )
1271 .unwrap();
1272
1273 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1274 assert!(removed);
1275
1276 let val = read_json(&path);
1277 assert!(val["env"].get("A_KEY").is_none());
1278 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1279 }
1280}