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)]
109pub struct Settings {
110 #[serde(default)]
113 pub env: HashMap<String, String>,
114
115 #[serde(default)]
118 pub profiles: HashMap<String, Profile>,
119}
120
121pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
127 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
128}
129
130#[must_use]
134pub fn profile_suffix(profile: Option<&str>) -> String {
135 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
136}
137
138#[derive(Debug, Default)]
149pub struct SettingsEnv {
150 settings: Settings,
151 active_profile: Option<String>,
152}
153
154impl SettingsEnv {
155 pub fn load() -> Self {
159 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
160 }
161
162 pub fn load_with_profile(profile: Option<&str>) -> Self {
166 Self {
167 settings: Settings::load().unwrap_or_default(),
168 active_profile: profile.map(str::to_string),
169 }
170 }
171}
172
173impl EnvSource for SettingsEnv {
174 fn var(&self, key: &str) -> Option<String> {
175 self.settings
176 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
177 }
178}
179
180impl Settings {
181 pub fn load() -> Result<Self> {
183 let settings_path = Self::get_settings_path()?;
184 Self::load_from_path(&settings_path)
185 }
186
187 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
189 let path = path.as_ref();
190
191 if !path.exists() {
193 return Ok(Self::default());
194 }
195
196 let content = fs::read_to_string(path)
198 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
199
200 serde_json::from_str::<Self>(&content)
201 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
202 }
203
204 pub fn get_settings_path() -> Result<PathBuf> {
206 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
207
208 Ok(home_dir.join(".omni-dev").join("settings.json"))
209 }
210
211 pub fn get_env_var(&self, key: &str) -> Option<String> {
214 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
215 }
216
217 pub fn resolve_with<E: EnvSource>(
226 &self,
227 raw: &E,
228 active: Option<&str>,
229 key: &str,
230 ) -> Option<String> {
231 self.resolve_with_source(raw, active, key)
232 .map(|(value, _)| value)
233 }
234
235 pub fn resolve_with_source<E: EnvSource>(
243 &self,
244 raw: &E,
245 active: Option<&str>,
246 key: &str,
247 ) -> Option<(String, EnvValueSource)> {
248 if let Some(value) = raw.var(key) {
249 return Some((value, EnvValueSource::ProcessEnv));
250 }
251 match active {
252 Some(name) => self
253 .profiles
254 .get(name)
255 .and_then(|p| p.env.get(key).cloned())
256 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
257 None => self
258 .env
259 .get(key)
260 .cloned()
261 .map(|value| (value, EnvValueSource::SettingsEnv)),
262 }
263 }
264
265 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
269 Self::upsert_env_vars_in(path, None, vars)
270 }
271
272 pub fn upsert_env_vars_in(
287 path: &Path,
288 profile: Option<&str>,
289 vars: &[(&str, &str)],
290 ) -> Result<()> {
291 let mut settings_value = read_or_default_settings(path)?;
292
293 let env = ensure_env_object(&mut settings_value, profile)?;
294 for (key, value) in vars {
295 env.insert(
296 (*key).to_string(),
297 serde_json::Value::String((*value).to_string()),
298 );
299 }
300
301 write_settings(path, &settings_value)
302 }
303
304 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
307 Self::remove_env_vars_in(path, None, keys)
308 }
309
310 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
321 if !path.exists() {
322 return Ok(false);
323 }
324 let mut settings_value = read_or_default_settings(path)?;
325
326 let mut removed = false;
327 if let Some(env) = env_object_mut(&mut settings_value, profile) {
328 for key in keys {
329 if env.remove(*key).is_some() {
330 removed = true;
331 }
332 }
333 }
334
335 if removed {
336 write_settings(path, &settings_value)?;
337 }
338 Ok(removed)
339 }
340
341 pub fn validate_profile(&self, name: &str) -> Result<()> {
345 if self.profiles.contains_key(name) {
346 return Ok(());
347 }
348 let known = if self.profiles.is_empty() {
349 "(none)".to_string()
350 } else {
351 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
352 names.sort_unstable();
353 names.join(", ")
354 };
355 Err(anyhow::anyhow!(
356 "unknown profile '{name}'; known profiles: {known}"
357 ))
358 }
359}
360
361fn ensure_env_object<'a>(
366 root: &'a mut serde_json::Value,
367 profile: Option<&str>,
368) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
369 let parent = match profile {
370 Some(name) => {
371 if !root
372 .get("profiles")
373 .is_some_and(serde_json::Value::is_object)
374 {
375 root["profiles"] = serde_json::json!({});
376 }
377 let profiles = &mut root["profiles"];
378 if !profiles.get(name).is_some_and(serde_json::Value::is_object) {
379 profiles[name] = serde_json::json!({});
380 }
381 &mut profiles[name]
382 }
383 None => root,
384 };
385
386 if !parent.get("env").is_some_and(serde_json::Value::is_object) {
387 parent["env"] = serde_json::json!({});
388 }
389 parent["env"]
390 .as_object_mut()
391 .context("Internal error: env key is not an object after initialization")
392}
393
394fn env_object_mut<'a>(
398 root: &'a mut serde_json::Value,
399 profile: Option<&str>,
400) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
401 let parent = match profile {
402 Some(name) => root.get_mut("profiles")?.get_mut(name)?,
403 None => root,
404 };
405 parent
406 .get_mut("env")
407 .and_then(serde_json::Value::as_object_mut)
408}
409
410fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
413 if path.exists() {
414 let content = fs::read_to_string(path)
415 .with_context(|| format!("Failed to read {}", path.display()))?;
416 serde_json::from_str(&content)
417 .with_context(|| format!("Failed to parse {}", path.display()))
418 } else {
419 Ok(serde_json::json!({}))
420 }
421}
422
423fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
428 if let Some(parent) = path.parent() {
429 if !parent.as_os_str().is_empty() {
430 crate::daemon::paths::ensure_dir_0700(parent)?;
431 }
432 }
433 let formatted =
434 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
435 write_file_0600(path, &formatted)
436 .with_context(|| format!("Failed to write {}", path.display()))?;
437 crate::daemon::paths::set_file_0600(path)?;
438 Ok(())
439}
440
441#[cfg(unix)]
443fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
444 use std::io::Write;
445 use std::os::unix::fs::OpenOptionsExt;
446
447 let mut file = fs::OpenOptions::new()
448 .write(true)
449 .create(true)
450 .truncate(true)
451 .mode(0o600)
452 .open(path)?;
453 file.write_all(contents.as_bytes())
454}
455
456#[cfg(not(unix))]
459fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
460 fs::write(path, contents)
461}
462
463pub fn get_env_var(key: &str) -> Result<String> {
466 get_env_var_with(&SystemEnv, Settings::load, key)
467}
468
469pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
476 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
477}
478
479fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
482where
483 E: EnvSource,
484 F: FnOnce() -> Result<Settings>,
485{
486 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
487}
488
489fn get_env_var_sourced_with<E, F>(
497 env: &E,
498 load: F,
499 from_cli_flag: bool,
500 key: &str,
501) -> Result<(String, EnvValueSource)>
502where
503 E: EnvSource,
504 F: FnOnce() -> Result<Settings>,
505{
506 if let Some(value) = env.var(key) {
510 let source = if from_cli_flag {
511 EnvValueSource::CliFlag
512 } else {
513 EnvValueSource::ProcessEnv
514 };
515 return Ok((value, source));
516 }
517 match load() {
518 Ok(settings) => settings
519 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
520 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
521 Err(err) => {
522 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
524 }
525 }
526}
527
528pub fn get_env_vars(keys: &[&str]) -> Result<String> {
530 for key in keys {
531 if let Ok(value) = get_env_var(key) {
532 return Ok(value);
533 }
534 }
535
536 Err(anyhow::anyhow!(
537 "None of the environment variables found: {keys:?}"
538 ))
539}
540
541#[cfg(test)]
542#[allow(clippy::unwrap_used, clippy::expect_used)]
543mod tests {
544 use super::*;
545 use crate::test_support::env::MapEnv;
546 use std::env;
547 use std::fs;
548 use tempfile::TempDir;
549
550 fn settings_with_profile() -> Settings {
553 let mut base = HashMap::new();
554 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
555 base.insert("SHARED".to_string(), "base-shared".to_string());
556
557 let mut work_env = HashMap::new();
558 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
559
560 let mut profiles = HashMap::new();
561 profiles.insert("work".to_string(), Profile { env: work_env });
562
563 Settings {
564 env: base,
565 profiles,
566 }
567 }
568
569 #[test]
570 fn settings_load_from_path() {
571 let temp_dir = {
573 std::fs::create_dir_all("tmp").ok();
574 TempDir::new_in("tmp").unwrap()
575 };
576 let settings_path = temp_dir.path().join("settings.json");
577
578 let settings_json = r#"{
580 "env": {
581 "TEST_VAR": "test_value",
582 "CLAUDE_API_KEY": "test_api_key"
583 }
584 }"#;
585 fs::write(&settings_path, settings_json).unwrap();
586
587 let settings = Settings::load_from_path(&settings_path).unwrap();
589
590 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
592 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
593 }
594
595 #[test]
596 fn settings_get_env_var() {
597 let temp_dir = {
599 std::fs::create_dir_all("tmp").ok();
600 TempDir::new_in("tmp").unwrap()
601 };
602 let settings_path = temp_dir.path().join("settings.json");
603
604 let settings_json = r#"{
606 "env": {
607 "TEST_VAR": "test_value",
608 "CLAUDE_API_KEY": "test_api_key"
609 }
610 }"#;
611 fs::write(&settings_path, settings_json).unwrap();
612
613 let settings = Settings::load_from_path(&settings_path).unwrap();
615
616 env::set_var("TEST_VAR_ENV", "env_value");
618
619 env::set_var("TEST_VAR", "env_override");
621 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
622
623 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
626
627 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
629
630 env::remove_var("TEST_VAR_ENV");
632 }
633
634 #[test]
637 fn resolve_no_profile_uses_base_env() {
638 let settings = settings_with_profile();
639 let raw = MapEnv::new();
640 assert_eq!(
641 settings
642 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
643 .as_deref(),
644 Some("base@x.com")
645 );
646 }
647
648 #[test]
649 fn resolve_active_profile_uses_profile_env() {
650 let settings = settings_with_profile();
651 let raw = MapEnv::new();
652 assert_eq!(
653 settings
654 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
655 .as_deref(),
656 Some("me@work.com")
657 );
658 }
659
660 #[test]
661 fn resolve_active_profile_does_not_consult_base() {
662 let settings = settings_with_profile();
665 let raw = MapEnv::new();
666 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
667 }
668
669 #[test]
670 fn resolve_process_env_wins_over_profile_and_base() {
671 let settings = settings_with_profile();
672 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
673 assert_eq!(
674 settings
675 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
676 .as_deref(),
677 Some("cli@x.com")
678 );
679 assert_eq!(
680 settings
681 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
682 .as_deref(),
683 Some("cli@x.com")
684 );
685 }
686
687 #[test]
688 fn resolve_unknown_active_profile_yields_none() {
689 let settings = settings_with_profile();
692 let raw = MapEnv::new();
693 assert_eq!(
694 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
695 None
696 );
697 }
698
699 #[test]
702 fn resolve_with_source_process_env_is_process_env() {
703 let settings = settings_with_profile();
704 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
705 assert_eq!(
706 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
707 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
708 );
709 }
710
711 #[test]
712 fn resolve_with_source_base_env_is_settings_env() {
713 let settings = settings_with_profile();
714 let raw = MapEnv::new();
715 assert_eq!(
716 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
717 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
718 );
719 }
720
721 #[test]
722 fn resolve_with_source_profile_env_names_profile() {
723 let settings = settings_with_profile();
724 let raw = MapEnv::new();
725 assert_eq!(
726 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
727 Some((
728 "me@work.com".to_string(),
729 EnvValueSource::SettingsProfile("work".to_string())
730 ))
731 );
732 }
733
734 #[test]
735 fn resolve_with_source_missing_key_is_none() {
736 let settings = settings_with_profile();
737 let raw = MapEnv::new();
738 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
739 }
740
741 #[test]
742 fn env_value_source_display_names_each_layer() {
743 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
744 assert_eq!(
745 EnvValueSource::ProcessEnv.to_string(),
746 "process environment variable (e.g. a shell export)"
747 );
748 assert_eq!(
749 EnvValueSource::SettingsEnv.to_string(),
750 "the env map in $HOME/.omni-dev/settings.json"
751 );
752 assert_eq!(
753 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
754 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
755 );
756 }
757
758 #[test]
759 fn active_profile_from_reads_and_trims_empty() {
760 assert_eq!(active_profile_from(&MapEnv::new()), None);
761 assert_eq!(
762 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
763 None
764 );
765 assert_eq!(
766 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
767 Some("work")
768 );
769 }
770
771 #[test]
772 fn profile_suffix_names_profile_or_is_empty() {
773 assert_eq!(profile_suffix(None), "");
774 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
775 }
776
777 #[test]
778 fn validate_profile_accepts_known() {
779 assert!(settings_with_profile().validate_profile("work").is_ok());
780 }
781
782 #[test]
783 fn validate_profile_rejects_unknown_and_lists_sorted() {
784 let mut settings = settings_with_profile();
785 settings
786 .profiles
787 .insert("personal".to_string(), Profile::default());
788 let err = settings.validate_profile("wrok").unwrap_err().to_string();
789 assert_eq!(
790 err,
791 "unknown profile 'wrok'; known profiles: personal, work"
792 );
793 }
794
795 #[test]
796 fn validate_profile_reports_none_when_empty() {
797 let settings = Settings::default();
798 let err = settings.validate_profile("work").unwrap_err().to_string();
799 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
800 }
801
802 #[test]
803 fn settings_parse_profiles_from_json() {
804 let json = r#"{
805 "env": { "BASE": "b" },
806 "profiles": {
807 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
808 }
809 }"#;
810 let settings: Settings = serde_json::from_str(json).unwrap();
811 assert_eq!(settings.env.get("BASE").unwrap(), "b");
812 assert_eq!(
813 settings
814 .profiles
815 .get("work")
816 .unwrap()
817 .env
818 .get("ATLASSIAN_EMAIL")
819 .unwrap(),
820 "me@work.com"
821 );
822 }
823
824 #[test]
825 fn settings_without_profiles_key_defaults_empty() {
826 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
827 assert!(settings.profiles.is_empty());
828 }
829
830 #[test]
833 fn get_env_var_with_returns_raw_hit_without_loading() {
834 let env = MapEnv::new().with("K", "v");
835 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
836 assert_eq!(value, "v");
837 }
838
839 #[test]
840 fn get_env_var_with_falls_back_to_base_settings() {
841 let settings = settings_with_profile();
842 let env = MapEnv::new();
843 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
844 assert_eq!(value, "base@x.com");
845 }
846
847 #[test]
848 fn get_env_var_with_honours_active_profile() {
849 let settings = settings_with_profile();
850 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
851 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
852 assert_eq!(value, "me@work.com");
853 }
854
855 #[test]
856 fn get_env_var_with_missing_key_is_not_found() {
857 let env = MapEnv::new();
858 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
859 .unwrap_err()
860 .to_string();
861 assert!(err.contains("Environment variable not found: MISSING"));
862 }
863
864 #[test]
865 fn get_env_var_with_load_error_maps_to_not_found() {
866 let env = MapEnv::new();
867 let err =
868 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
869 assert_eq!(err.to_string(), "disk boom");
872 let chain = format!("{err:#}");
873 assert!(chain.contains("Environment variable not found: MISSING"));
874 }
875
876 #[test]
879 fn get_env_var_sourced_with_raw_hit_is_process_env() {
880 let env = MapEnv::new().with("K", "v");
881 let resolved =
882 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
883 .unwrap();
884 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
885 }
886
887 #[test]
888 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
889 let env = MapEnv::new().with("K", "true");
890 let resolved =
891 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
892 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
893 }
894
895 #[test]
896 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
897 let settings = settings_with_profile();
898 let env = MapEnv::new();
899 let resolved =
900 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
901 assert_eq!(
902 resolved,
903 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
904 );
905
906 let settings = settings_with_profile();
907 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
908 let resolved =
909 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
910 assert_eq!(
911 resolved,
912 (
913 "me@work.com".to_string(),
914 EnvValueSource::SettingsProfile("work".to_string())
915 )
916 );
917 }
918
919 #[test]
920 fn cli_flag_export_registry_roundtrip() {
921 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
924 assert!(!exported_by_cli_flag(KEY));
925 note_cli_flag_export(KEY);
926 assert!(exported_by_cli_flag(KEY));
927 }
928
929 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
934 let temp_dir = {
935 std::fs::create_dir_all("tmp").ok();
936 TempDir::new_in("tmp").unwrap()
937 };
938 let path = temp_dir.path().join(".omni-dev").join("settings.json");
939 (temp_dir, path)
940 }
941
942 fn read_json(path: &Path) -> serde_json::Value {
943 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
944 }
945
946 #[test]
947 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
948 let (_tmp, path) = temp_settings_path();
949
950 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
951
952 let val = read_json(&path);
953 assert_eq!(val["env"]["A_KEY"], "a");
954 assert_eq!(val["env"]["B_KEY"], "b");
955
956 #[cfg(unix)]
958 {
959 use std::os::unix::fs::PermissionsExt;
960 let dir_mode = fs::metadata(path.parent().unwrap())
961 .unwrap()
962 .permissions()
963 .mode();
964 assert_eq!(dir_mode & 0o777, 0o700);
965 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
966 assert_eq!(file_mode & 0o777, 0o600);
967 }
968 }
969
970 #[test]
971 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
972 let (_tmp, path) = temp_settings_path();
973 fs::create_dir_all(path.parent().unwrap()).unwrap();
974 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
975
976 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
977
978 let val = read_json(&path);
979 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
980 assert_eq!(val["extra"], true);
981 assert_eq!(val["env"]["A_KEY"], "new");
982 }
983
984 #[test]
985 fn upsert_env_vars_replaces_non_object_env() {
986 let (_tmp, path) = temp_settings_path();
987 fs::create_dir_all(path.parent().unwrap()).unwrap();
988 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
989
990 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
991
992 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
993 }
994
995 #[cfg(unix)]
996 #[test]
997 fn upsert_env_vars_retightens_loose_permissions() {
998 use std::os::unix::fs::PermissionsExt;
999
1000 let (_tmp, path) = temp_settings_path();
1001 fs::create_dir_all(path.parent().unwrap()).unwrap();
1002 fs::write(&path, r#"{"env": {}}"#).unwrap();
1003 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1004
1005 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1006
1007 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1008 assert_eq!(file_mode & 0o777, 0o600);
1009 }
1010
1011 #[test]
1012 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1013 let (_tmp, path) = temp_settings_path();
1014 fs::create_dir_all(path.parent().unwrap()).unwrap();
1015 fs::write(
1016 &path,
1017 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1018 )
1019 .unwrap();
1020
1021 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1022 assert!(removed);
1023
1024 let val = read_json(&path);
1025 assert!(val["env"].get("A_KEY").is_none());
1026 assert!(val["env"].get("B_KEY").is_none());
1027 assert_eq!(val["env"]["OTHER_KEY"], "keep");
1028 assert_eq!(val["extra"], true);
1029 }
1030
1031 #[test]
1032 fn remove_env_vars_false_when_file_missing() {
1033 let (_tmp, path) = temp_settings_path();
1034 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1035 assert!(!path.exists());
1036 }
1037
1038 #[test]
1039 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1040 let (_tmp, path) = temp_settings_path();
1041 fs::create_dir_all(path.parent().unwrap()).unwrap();
1042
1043 fs::write(&path, r#"{"extra": true}"#).unwrap();
1045 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1046
1047 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1049 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1050 }
1051
1052 #[test]
1053 fn upsert_env_vars_bare_filename_skips_dir_creation() {
1054 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1057 let path = Path::new(&name);
1058
1059 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1060
1061 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1062 fs::remove_file(path).unwrap();
1063 }
1064
1065 #[test]
1066 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1067 let (_tmp, path) = temp_settings_path();
1068 fs::create_dir_all(path.parent().unwrap()).unwrap();
1069 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1070 fs::write(&path, original).unwrap();
1071
1072 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1073 assert!(!removed);
1074 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1076 }
1077
1078 #[test]
1081 fn upsert_env_vars_in_profile_creates_profile_env() {
1082 let (_tmp, path) = temp_settings_path();
1083
1084 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1085
1086 let val = read_json(&path);
1087 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1088 assert!(val.get("env").is_none());
1090
1091 #[cfg(unix)]
1094 {
1095 use std::os::unix::fs::PermissionsExt;
1096 let dir_mode = fs::metadata(path.parent().unwrap())
1097 .unwrap()
1098 .permissions()
1099 .mode();
1100 assert_eq!(dir_mode & 0o777, 0o700);
1101 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1102 assert_eq!(file_mode & 0o777, 0o600);
1103 }
1104 }
1105
1106 #[test]
1107 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1108 let (_tmp, path) = temp_settings_path();
1109 fs::create_dir_all(path.parent().unwrap()).unwrap();
1110 fs::write(
1111 &path,
1112 r#"{
1113 "env": {"SHARED": "base"},
1114 "profiles": {
1115 "work": {"env": {"OLD": "keep"}},
1116 "home": {"env": {"SHARED": "home"}}
1117 },
1118 "extra": true
1119 }"#,
1120 )
1121 .unwrap();
1122
1123 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1124
1125 let val = read_json(&path);
1126 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1127 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1128 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1129 assert_eq!(val["env"]["SHARED"], "base");
1130 assert_eq!(val["extra"], true);
1131 }
1132
1133 #[test]
1134 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1135 let (_tmp, path) = temp_settings_path();
1136 fs::create_dir_all(path.parent().unwrap()).unwrap();
1137
1138 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1140 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1141 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1142
1143 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1145 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1146 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1147 }
1148
1149 #[test]
1150 fn remove_env_vars_in_profile_removes_only_profile_keys() {
1151 let (_tmp, path) = temp_settings_path();
1152 fs::create_dir_all(path.parent().unwrap()).unwrap();
1153 fs::write(
1154 &path,
1155 r#"{
1156 "env": {"A_KEY": "base"},
1157 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1158 }"#,
1159 )
1160 .unwrap();
1161
1162 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1163 assert!(removed);
1164
1165 let val = read_json(&path);
1166 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1167 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1168 assert_eq!(val["env"]["A_KEY"], "base");
1170 }
1171
1172 #[test]
1173 fn remove_env_vars_in_profile_false_when_profile_missing() {
1174 let (_tmp, path) = temp_settings_path();
1175 fs::create_dir_all(path.parent().unwrap()).unwrap();
1176 let original = r#"{"env": {"A_KEY": "base"}}"#;
1177 fs::write(&path, original).unwrap();
1178
1179 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1180 assert!(!removed);
1181 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1183 }
1184
1185 #[test]
1186 fn remove_env_vars_in_none_targets_base_env() {
1187 let (_tmp, path) = temp_settings_path();
1188 fs::create_dir_all(path.parent().unwrap()).unwrap();
1189 fs::write(
1190 &path,
1191 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1192 )
1193 .unwrap();
1194
1195 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1196 assert!(removed);
1197
1198 let val = read_json(&path);
1199 assert!(val["env"].get("A_KEY").is_none());
1200 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1201 }
1202}