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)]
141pub struct GmailAccountSettings {
142 #[serde(default)]
144 pub client_id: Option<String>,
145 #[serde(default)]
147 pub client_secret: Option<String>,
148 #[serde(default)]
150 pub refresh_token: Option<String>,
151 #[serde(default)]
153 pub scope: Option<String>,
154 #[serde(default)]
161 pub email_address: Option<String>,
162
163 #[serde(default)]
171 pub chrome_profile_from_email: bool,
172
173 #[serde(default)]
180 pub browser_command: Option<String>,
181}
182
183#[derive(Debug, Default, Deserialize)]
191pub struct GmailSettings {
192 #[serde(default)]
196 pub default_account: Option<String>,
197
198 #[serde(default)]
200 pub accounts: HashMap<String, GmailAccountSettings>,
201}
202
203#[derive(Debug, Default, Deserialize)]
213pub struct DriveAccountSettings {
214 #[serde(default)]
216 pub client_id: Option<String>,
217 #[serde(default)]
219 pub client_secret: Option<String>,
220 #[serde(default)]
223 pub refresh_token: Option<String>,
224 #[serde(default)]
235 pub scope: Option<String>,
236 #[serde(default)]
242 pub email_address: Option<String>,
243
244 #[serde(default)]
254 pub chrome_profile_from_email: bool,
255
256 #[serde(default)]
264 pub browser_command: Option<String>,
265}
266
267#[derive(Debug, Default, Deserialize)]
275pub struct DriveSettings {
276 #[serde(default)]
280 pub default_account: Option<String>,
281
282 #[serde(default)]
284 pub accounts: HashMap<String, DriveAccountSettings>,
285}
286
287#[derive(Debug, Default, Deserialize)]
289pub struct Settings {
290 #[serde(default)]
293 pub env: HashMap<String, String>,
294
295 #[serde(default)]
298 pub profiles: HashMap<String, Profile>,
299
300 #[serde(default)]
303 pub mcp: McpSettings,
304
305 #[serde(default)]
308 pub gmail: GmailSettings,
309
310 #[serde(default)]
313 pub drive: DriveSettings,
314}
315
316pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
322 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
323}
324
325#[must_use]
329pub fn profile_suffix(profile: Option<&str>) -> String {
330 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
331}
332
333#[derive(Debug, Default)]
344pub struct SettingsEnv {
345 settings: Settings,
346 active_profile: Option<String>,
347}
348
349impl SettingsEnv {
350 pub fn load() -> Self {
354 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
355 }
356
357 pub fn load_with_profile(profile: Option<&str>) -> Self {
361 Self {
362 settings: Settings::load().unwrap_or_default(),
363 active_profile: profile.map(str::to_string),
364 }
365 }
366
367 pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
374 Self {
375 settings,
376 active_profile: profile.map(str::to_string),
377 }
378 }
379}
380
381impl EnvSource for SettingsEnv {
382 fn var(&self, key: &str) -> Option<String> {
383 self.settings
384 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
385 }
386}
387
388impl Settings {
389 pub fn load() -> Result<Self> {
391 let settings_path = Self::get_settings_path()?;
392 Self::load_from_path(&settings_path)
393 }
394
395 pub fn load_mcp() -> McpSettings {
400 Self::load().map(|s| s.mcp).unwrap_or_default()
401 }
402
403 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
405 let path = path.as_ref();
406
407 if !path.exists() {
409 return Ok(Self::default());
410 }
411
412 let content = fs::read_to_string(path)
414 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
415
416 serde_json::from_str::<Self>(&content)
417 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
418 }
419
420 pub fn get_settings_path() -> Result<PathBuf> {
422 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
423
424 Ok(home_dir.join(".omni-dev").join("settings.json"))
425 }
426
427 pub fn get_env_var(&self, key: &str) -> Option<String> {
430 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
431 }
432
433 pub fn resolve_with<E: EnvSource>(
442 &self,
443 raw: &E,
444 active: Option<&str>,
445 key: &str,
446 ) -> Option<String> {
447 self.resolve_with_source(raw, active, key)
448 .map(|(value, _)| value)
449 }
450
451 pub fn resolve_with_source<E: EnvSource>(
459 &self,
460 raw: &E,
461 active: Option<&str>,
462 key: &str,
463 ) -> Option<(String, EnvValueSource)> {
464 if let Some(value) = raw.var(key) {
465 return Some((value, EnvValueSource::ProcessEnv));
466 }
467 match active {
468 Some(name) => self
469 .profiles
470 .get(name)
471 .and_then(|p| p.env.get(key).cloned())
472 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
473 None => self
474 .env
475 .get(key)
476 .cloned()
477 .map(|value| (value, EnvValueSource::SettingsEnv)),
478 }
479 }
480
481 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
485 Self::upsert_env_vars_in(path, None, vars)
486 }
487
488 pub fn upsert_env_vars_in(
503 path: &Path,
504 profile: Option<&str>,
505 vars: &[(&str, &str)],
506 ) -> Result<()> {
507 let mut settings_value = read_or_default_settings(path)?;
508
509 let env = ensure_env_object(&mut settings_value, profile)?;
510 for (key, value) in vars {
511 env.insert(
512 (*key).to_string(),
513 serde_json::Value::String((*value).to_string()),
514 );
515 }
516
517 write_settings(path, &settings_value)
518 }
519
520 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
523 Self::remove_env_vars_in(path, None, keys)
524 }
525
526 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
537 if !path.exists() {
538 return Ok(false);
539 }
540 let mut settings_value = read_or_default_settings(path)?;
541
542 let mut removed = false;
543 if let Some(env) = env_object_mut(&mut settings_value, profile) {
544 for key in keys {
545 if env.remove(*key).is_some() {
546 removed = true;
547 }
548 }
549 }
550
551 if removed {
552 write_settings(path, &settings_value)?;
553 }
554 Ok(removed)
555 }
556
557 pub fn validate_profile(&self, name: &str) -> Result<()> {
561 if self.profiles.contains_key(name) {
562 return Ok(());
563 }
564 let known = if self.profiles.is_empty() {
565 "(none)".to_string()
566 } else {
567 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
568 names.sort_unstable();
569 names.join(", ")
570 };
571 Err(anyhow::anyhow!(
572 "unknown profile '{name}'; known profiles: {known}"
573 ))
574 }
575
576 pub fn upsert_gmail_account(
592 path: &Path,
593 account: &str,
594 vars: &[(&str, serde_json::Value)],
595 ) -> Result<()> {
596 let mut settings_value = read_or_default_settings(path)?;
597
598 let entry = ensure_object_at(&mut settings_value, &["gmail", "accounts", account])?;
599 for (key, value) in vars {
600 entry.insert((*key).to_string(), value.clone());
601 }
602
603 write_settings(path, &settings_value)
604 }
605
606 pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
617 if !path.exists() {
618 return Ok(false);
619 }
620 let mut settings_value = read_or_default_settings(path)?;
621
622 let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
623 .is_some_and(|accounts| accounts.remove(account).is_some());
624
625 if removed {
626 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
627 if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
628 gmail.remove("default_account");
629 }
630 }
631 write_settings(path, &settings_value)?;
632 }
633 Ok(removed)
634 }
635
636 pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
642 let mut settings_value = read_or_default_settings(path)?;
643
644 match account {
645 Some(name) => {
646 let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
647 gmail.insert(
648 "default_account".to_string(),
649 serde_json::Value::String(name.to_string()),
650 );
651 }
652 None => {
653 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
654 gmail.remove("default_account");
655 }
656 }
657 }
658
659 write_settings(path, &settings_value)
660 }
661
662 pub fn upsert_drive_account(
674 path: &Path,
675 account: &str,
676 vars: &[(&str, serde_json::Value)],
677 ) -> Result<()> {
678 let mut settings_value = read_or_default_settings(path)?;
679
680 let entry = ensure_object_at(&mut settings_value, &["drive", "accounts", account])?;
681 for (key, value) in vars {
682 entry.insert((*key).to_string(), value.clone());
683 }
684
685 write_settings(path, &settings_value)
686 }
687
688 pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
698 if !path.exists() {
699 return Ok(false);
700 }
701 let mut settings_value = read_or_default_settings(path)?;
702
703 let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
704 .is_some_and(|accounts| accounts.remove(account).is_some());
705
706 if removed {
707 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
708 if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
709 drive.remove("default_account");
710 }
711 }
712 write_settings(path, &settings_value)?;
713 }
714 Ok(removed)
715 }
716
717 pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
722 let mut settings_value = read_or_default_settings(path)?;
723
724 match account {
725 Some(name) => {
726 let drive = ensure_object_at(&mut settings_value, &["drive"])?;
727 drive.insert(
728 "default_account".to_string(),
729 serde_json::Value::String(name.to_string()),
730 );
731 }
732 None => {
733 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
734 drive.remove("default_account");
735 }
736 }
737 }
738
739 write_settings(path, &settings_value)
740 }
741}
742
743fn ensure_env_object<'a>(
747 root: &'a mut serde_json::Value,
748 profile: Option<&str>,
749) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
750 match profile {
751 Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
752 None => ensure_object_at(root, &["env"]),
753 }
754}
755
756fn env_object_mut<'a>(
760 root: &'a mut serde_json::Value,
761 profile: Option<&str>,
762) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
763 match profile {
764 Some(name) => object_at_mut(root, &["profiles", name, "env"]),
765 None => object_at_mut(root, &["env"]),
766 }
767}
768
769fn ensure_object_at<'a>(
777 root: &'a mut serde_json::Value,
778 segments: &[&str],
779) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
780 let mut current = root;
781 for segment in segments {
782 if !current
783 .get(*segment)
784 .is_some_and(serde_json::Value::is_object)
785 {
786 current[*segment] = serde_json::json!({});
787 }
788 current = current
789 .get_mut(*segment)
790 .context("Internal error: target key missing immediately after being created")?;
791 }
792 current
793 .as_object_mut()
794 .context("Internal error: target key is not an object after initialization")
795}
796
797fn object_at_mut<'a>(
801 root: &'a mut serde_json::Value,
802 segments: &[&str],
803) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
804 let mut current = root;
805 for segment in segments {
806 current = current.get_mut(*segment)?;
807 }
808 current.as_object_mut()
809}
810
811fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
814 if path.exists() {
815 let content = fs::read_to_string(path)
816 .with_context(|| format!("Failed to read {}", path.display()))?;
817 serde_json::from_str(&content)
818 .with_context(|| format!("Failed to parse {}", path.display()))
819 } else {
820 Ok(serde_json::json!({}))
821 }
822}
823
824fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
829 if let Some(parent) = path.parent() {
830 if !parent.as_os_str().is_empty() {
831 crate::daemon::paths::ensure_dir_0700(parent)?;
832 }
833 }
834 let formatted =
835 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
836 write_file_0600(path, &formatted)
837 .with_context(|| format!("Failed to write {}", path.display()))?;
838 crate::daemon::paths::set_file_0600(path)?;
839 Ok(())
840}
841
842#[cfg(unix)]
844fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
845 use std::io::Write;
846 use std::os::unix::fs::OpenOptionsExt;
847
848 let mut file = fs::OpenOptions::new()
849 .write(true)
850 .create(true)
851 .truncate(true)
852 .mode(0o600)
853 .open(path)?;
854 file.write_all(contents.as_bytes())
855}
856
857#[cfg(not(unix))]
860fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
861 fs::write(path, contents)
862}
863
864pub fn get_env_var(key: &str) -> Result<String> {
867 get_env_var_with(&SystemEnv, Settings::load, key)
868}
869
870pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
877 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
878}
879
880fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
883where
884 E: EnvSource,
885 F: FnOnce() -> Result<Settings>,
886{
887 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
888}
889
890fn get_env_var_sourced_with<E, F>(
898 env: &E,
899 load: F,
900 from_cli_flag: bool,
901 key: &str,
902) -> Result<(String, EnvValueSource)>
903where
904 E: EnvSource,
905 F: FnOnce() -> Result<Settings>,
906{
907 if let Some(value) = env.var(key) {
911 let source = if from_cli_flag {
912 EnvValueSource::CliFlag
913 } else {
914 EnvValueSource::ProcessEnv
915 };
916 return Ok((value, source));
917 }
918 match load() {
919 Ok(settings) => settings
920 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
921 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
922 Err(err) => {
923 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
925 }
926 }
927}
928
929pub fn get_env_vars(keys: &[&str]) -> Result<String> {
931 for key in keys {
932 if let Ok(value) = get_env_var(key) {
933 return Ok(value);
934 }
935 }
936
937 Err(anyhow::anyhow!(
938 "None of the environment variables found: {keys:?}"
939 ))
940}
941
942#[cfg(test)]
943#[allow(clippy::unwrap_used, clippy::expect_used)]
944mod tests {
945 use super::*;
946 use crate::test_support::env::MapEnv;
947 use std::env;
948 use std::fs;
949 use tempfile::TempDir;
950
951 fn settings_with_profile() -> Settings {
954 let mut base = HashMap::new();
955 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
956 base.insert("SHARED".to_string(), "base-shared".to_string());
957
958 let mut work_env = HashMap::new();
959 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
960
961 let mut profiles = HashMap::new();
962 profiles.insert("work".to_string(), Profile { env: work_env });
963
964 Settings {
965 env: base,
966 profiles,
967 ..Settings::default()
968 }
969 }
970
971 #[test]
972 fn settings_load_from_path() {
973 let temp_dir = {
975 std::fs::create_dir_all("tmp").ok();
976 TempDir::new_in("tmp").unwrap()
977 };
978 let settings_path = temp_dir.path().join("settings.json");
979
980 let settings_json = r#"{
982 "env": {
983 "TEST_VAR": "test_value",
984 "CLAUDE_API_KEY": "test_api_key"
985 }
986 }"#;
987 fs::write(&settings_path, settings_json).unwrap();
988
989 let settings = Settings::load_from_path(&settings_path).unwrap();
991
992 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
994 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
995 }
996
997 #[test]
998 fn settings_get_env_var() {
999 let temp_dir = {
1001 std::fs::create_dir_all("tmp").ok();
1002 TempDir::new_in("tmp").unwrap()
1003 };
1004 let settings_path = temp_dir.path().join("settings.json");
1005
1006 let settings_json = r#"{
1008 "env": {
1009 "TEST_VAR": "test_value",
1010 "CLAUDE_API_KEY": "test_api_key"
1011 }
1012 }"#;
1013 fs::write(&settings_path, settings_json).unwrap();
1014
1015 let settings = Settings::load_from_path(&settings_path).unwrap();
1017
1018 env::set_var("TEST_VAR_ENV", "env_value");
1020
1021 env::set_var("TEST_VAR", "env_override");
1023 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1024
1025 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1028
1029 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1031
1032 env::remove_var("TEST_VAR_ENV");
1034 }
1035
1036 #[test]
1039 fn resolve_no_profile_uses_base_env() {
1040 let settings = settings_with_profile();
1041 let raw = MapEnv::new();
1042 assert_eq!(
1043 settings
1044 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1045 .as_deref(),
1046 Some("base@x.com")
1047 );
1048 }
1049
1050 #[test]
1051 fn resolve_active_profile_uses_profile_env() {
1052 let settings = settings_with_profile();
1053 let raw = MapEnv::new();
1054 assert_eq!(
1055 settings
1056 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1057 .as_deref(),
1058 Some("me@work.com")
1059 );
1060 }
1061
1062 #[test]
1063 fn resolve_active_profile_does_not_consult_base() {
1064 let settings = settings_with_profile();
1067 let raw = MapEnv::new();
1068 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1069 }
1070
1071 #[test]
1072 fn resolve_process_env_wins_over_profile_and_base() {
1073 let settings = settings_with_profile();
1074 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1075 assert_eq!(
1076 settings
1077 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1078 .as_deref(),
1079 Some("cli@x.com")
1080 );
1081 assert_eq!(
1082 settings
1083 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1084 .as_deref(),
1085 Some("cli@x.com")
1086 );
1087 }
1088
1089 #[test]
1090 fn resolve_unknown_active_profile_yields_none() {
1091 let settings = settings_with_profile();
1094 let raw = MapEnv::new();
1095 assert_eq!(
1096 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1097 None
1098 );
1099 }
1100
1101 #[test]
1104 fn resolve_with_source_process_env_is_process_env() {
1105 let settings = settings_with_profile();
1106 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1107 assert_eq!(
1108 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1109 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1110 );
1111 }
1112
1113 #[test]
1114 fn resolve_with_source_base_env_is_settings_env() {
1115 let settings = settings_with_profile();
1116 let raw = MapEnv::new();
1117 assert_eq!(
1118 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1119 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1120 );
1121 }
1122
1123 #[test]
1124 fn resolve_with_source_profile_env_names_profile() {
1125 let settings = settings_with_profile();
1126 let raw = MapEnv::new();
1127 assert_eq!(
1128 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
1129 Some((
1130 "me@work.com".to_string(),
1131 EnvValueSource::SettingsProfile("work".to_string())
1132 ))
1133 );
1134 }
1135
1136 #[test]
1137 fn resolve_with_source_missing_key_is_none() {
1138 let settings = settings_with_profile();
1139 let raw = MapEnv::new();
1140 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
1141 }
1142
1143 #[test]
1144 fn env_value_source_display_names_each_layer() {
1145 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
1146 assert_eq!(
1147 EnvValueSource::ProcessEnv.to_string(),
1148 "process environment variable (e.g. a shell export)"
1149 );
1150 assert_eq!(
1151 EnvValueSource::SettingsEnv.to_string(),
1152 "the env map in $HOME/.omni-dev/settings.json"
1153 );
1154 assert_eq!(
1155 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
1156 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
1157 );
1158 }
1159
1160 #[test]
1161 fn active_profile_from_reads_and_trims_empty() {
1162 assert_eq!(active_profile_from(&MapEnv::new()), None);
1163 assert_eq!(
1164 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
1165 None
1166 );
1167 assert_eq!(
1168 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
1169 Some("work")
1170 );
1171 }
1172
1173 #[test]
1174 fn profile_suffix_names_profile_or_is_empty() {
1175 assert_eq!(profile_suffix(None), "");
1176 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
1177 }
1178
1179 #[test]
1180 fn validate_profile_accepts_known() {
1181 assert!(settings_with_profile().validate_profile("work").is_ok());
1182 }
1183
1184 #[test]
1185 fn validate_profile_rejects_unknown_and_lists_sorted() {
1186 let mut settings = settings_with_profile();
1187 settings
1188 .profiles
1189 .insert("personal".to_string(), Profile::default());
1190 let err = settings.validate_profile("wrok").unwrap_err().to_string();
1191 assert_eq!(
1192 err,
1193 "unknown profile 'wrok'; known profiles: personal, work"
1194 );
1195 }
1196
1197 #[test]
1198 fn validate_profile_reports_none_when_empty() {
1199 let settings = Settings::default();
1200 let err = settings.validate_profile("work").unwrap_err().to_string();
1201 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
1202 }
1203
1204 #[test]
1205 fn settings_parse_profiles_from_json() {
1206 let json = r#"{
1207 "env": { "BASE": "b" },
1208 "profiles": {
1209 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
1210 }
1211 }"#;
1212 let settings: Settings = serde_json::from_str(json).unwrap();
1213 assert_eq!(settings.env.get("BASE").unwrap(), "b");
1214 assert_eq!(
1215 settings
1216 .profiles
1217 .get("work")
1218 .unwrap()
1219 .env
1220 .get("ATLASSIAN_EMAIL")
1221 .unwrap(),
1222 "me@work.com"
1223 );
1224 }
1225
1226 #[test]
1227 fn settings_without_profiles_key_defaults_empty() {
1228 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1229 assert!(settings.profiles.is_empty());
1230 }
1231
1232 #[test]
1233 fn settings_parse_mcp_section_from_json() {
1234 let json = r#"{
1235 "mcp": {
1236 "default_model": "claude-sonnet-4-6",
1237 "log_level": "info",
1238 "max_response_bytes": 204800
1239 }
1240 }"#;
1241 let settings: Settings = serde_json::from_str(json).unwrap();
1242 assert_eq!(
1243 settings.mcp.default_model.as_deref(),
1244 Some("claude-sonnet-4-6")
1245 );
1246 assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
1247 assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
1248 }
1249
1250 #[test]
1251 fn settings_without_mcp_key_defaults_all_none() {
1252 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1255 assert!(settings.mcp.default_model.is_none());
1256 assert!(settings.mcp.log_level.is_none());
1257 assert!(settings.mcp.max_response_bytes.is_none());
1258 }
1259
1260 #[test]
1261 fn settings_mcp_partial_section_leaves_others_none() {
1262 let settings: Settings =
1264 serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
1265 assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
1266 assert!(settings.mcp.default_model.is_none());
1267 assert!(settings.mcp.max_response_bytes.is_none());
1268 }
1269
1270 #[test]
1271 fn settings_parse_gmail_section_from_json() {
1272 let json = r#"{
1273 "gmail": {
1274 "default_account": "work",
1275 "accounts": {
1276 "work": {
1277 "client_id": "id",
1278 "client_secret": "secret",
1279 "refresh_token": "token",
1280 "scope": "https://www.googleapis.com/auth/gmail.modify",
1281 "email_address": "alice@work.com"
1282 }
1283 }
1284 }
1285 }"#;
1286 let settings: Settings = serde_json::from_str(json).unwrap();
1287 assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
1288 let account = settings.gmail.accounts.get("work").unwrap();
1289 assert_eq!(account.client_id.as_deref(), Some("id"));
1290 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1291 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1292 assert_eq!(
1293 account.scope.as_deref(),
1294 Some("https://www.googleapis.com/auth/gmail.modify")
1295 );
1296 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1297 }
1298
1299 #[test]
1300 fn settings_without_gmail_key_defaults_empty() {
1301 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1302 assert!(settings.gmail.default_account.is_none());
1303 assert!(settings.gmail.accounts.is_empty());
1304 }
1305
1306 #[test]
1307 fn settings_parse_drive_section_from_json() {
1308 let json = r#"{
1309 "drive": {
1310 "default_account": "work",
1311 "accounts": {
1312 "work": {
1313 "client_id": "id",
1314 "client_secret": "secret",
1315 "refresh_token": "token",
1316 "scope": "https://www.googleapis.com/auth/drive.readonly",
1317 "email_address": "alice@work.com"
1318 }
1319 }
1320 }
1321 }"#;
1322 let settings: Settings = serde_json::from_str(json).unwrap();
1323 assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
1324 let account = settings.drive.accounts.get("work").unwrap();
1325 assert_eq!(account.client_id.as_deref(), Some("id"));
1326 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1327 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1328 assert_eq!(
1329 account.scope.as_deref(),
1330 Some("https://www.googleapis.com/auth/drive.readonly")
1331 );
1332 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1333 }
1334
1335 #[test]
1336 fn settings_without_drive_key_defaults_empty() {
1337 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1338 assert!(settings.drive.default_account.is_none());
1339 assert!(settings.drive.accounts.is_empty());
1340 }
1341
1342 #[test]
1345 fn get_env_var_with_returns_raw_hit_without_loading() {
1346 let env = MapEnv::new().with("K", "v");
1347 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
1348 assert_eq!(value, "v");
1349 }
1350
1351 #[test]
1352 fn get_env_var_with_falls_back_to_base_settings() {
1353 let settings = settings_with_profile();
1354 let env = MapEnv::new();
1355 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1356 assert_eq!(value, "base@x.com");
1357 }
1358
1359 #[test]
1360 fn get_env_var_with_honours_active_profile() {
1361 let settings = settings_with_profile();
1362 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1363 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1364 assert_eq!(value, "me@work.com");
1365 }
1366
1367 #[test]
1368 fn get_env_var_with_missing_key_is_not_found() {
1369 let env = MapEnv::new();
1370 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
1371 .unwrap_err()
1372 .to_string();
1373 assert!(err.contains("Environment variable not found: MISSING"));
1374 }
1375
1376 #[test]
1377 fn get_env_var_with_load_error_maps_to_not_found() {
1378 let env = MapEnv::new();
1379 let err =
1380 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
1381 assert_eq!(err.to_string(), "disk boom");
1384 let chain = format!("{err:#}");
1385 assert!(chain.contains("Environment variable not found: MISSING"));
1386 }
1387
1388 #[test]
1391 fn get_env_var_sourced_with_raw_hit_is_process_env() {
1392 let env = MapEnv::new().with("K", "v");
1393 let resolved =
1394 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
1395 .unwrap();
1396 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
1397 }
1398
1399 #[test]
1400 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
1401 let env = MapEnv::new().with("K", "true");
1402 let resolved =
1403 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
1404 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
1405 }
1406
1407 #[test]
1408 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
1409 let settings = settings_with_profile();
1410 let env = MapEnv::new();
1411 let resolved =
1412 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1413 assert_eq!(
1414 resolved,
1415 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
1416 );
1417
1418 let settings = settings_with_profile();
1419 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1420 let resolved =
1421 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1422 assert_eq!(
1423 resolved,
1424 (
1425 "me@work.com".to_string(),
1426 EnvValueSource::SettingsProfile("work".to_string())
1427 )
1428 );
1429 }
1430
1431 #[test]
1432 fn cli_flag_export_registry_roundtrip() {
1433 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
1436 assert!(!exported_by_cli_flag(KEY));
1437 note_cli_flag_export(KEY);
1438 assert!(exported_by_cli_flag(KEY));
1439 }
1440
1441 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
1446 let temp_dir = {
1447 std::fs::create_dir_all("tmp").ok();
1448 TempDir::new_in("tmp").unwrap()
1449 };
1450 let path = temp_dir.path().join(".omni-dev").join("settings.json");
1451 (temp_dir, path)
1452 }
1453
1454 fn read_json(path: &Path) -> serde_json::Value {
1455 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
1456 }
1457
1458 #[test]
1459 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
1460 let (_tmp, path) = temp_settings_path();
1461
1462 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
1463
1464 let val = read_json(&path);
1465 assert_eq!(val["env"]["A_KEY"], "a");
1466 assert_eq!(val["env"]["B_KEY"], "b");
1467
1468 #[cfg(unix)]
1470 {
1471 use std::os::unix::fs::PermissionsExt;
1472 let dir_mode = fs::metadata(path.parent().unwrap())
1473 .unwrap()
1474 .permissions()
1475 .mode();
1476 assert_eq!(dir_mode & 0o777, 0o700);
1477 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1478 assert_eq!(file_mode & 0o777, 0o600);
1479 }
1480 }
1481
1482 #[test]
1483 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
1484 let (_tmp, path) = temp_settings_path();
1485 fs::create_dir_all(path.parent().unwrap()).unwrap();
1486 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
1487
1488 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
1489
1490 let val = read_json(&path);
1491 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
1492 assert_eq!(val["extra"], true);
1493 assert_eq!(val["env"]["A_KEY"], "new");
1494 }
1495
1496 #[test]
1497 fn upsert_env_vars_replaces_non_object_env() {
1498 let (_tmp, path) = temp_settings_path();
1499 fs::create_dir_all(path.parent().unwrap()).unwrap();
1500 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1501
1502 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1503
1504 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
1505 }
1506
1507 #[cfg(unix)]
1508 #[test]
1509 fn upsert_env_vars_retightens_loose_permissions() {
1510 use std::os::unix::fs::PermissionsExt;
1511
1512 let (_tmp, path) = temp_settings_path();
1513 fs::create_dir_all(path.parent().unwrap()).unwrap();
1514 fs::write(&path, r#"{"env": {}}"#).unwrap();
1515 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1516
1517 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1518
1519 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1520 assert_eq!(file_mode & 0o777, 0o600);
1521 }
1522
1523 #[test]
1524 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1525 let (_tmp, path) = temp_settings_path();
1526 fs::create_dir_all(path.parent().unwrap()).unwrap();
1527 fs::write(
1528 &path,
1529 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1530 )
1531 .unwrap();
1532
1533 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1534 assert!(removed);
1535
1536 let val = read_json(&path);
1537 assert!(val["env"].get("A_KEY").is_none());
1538 assert!(val["env"].get("B_KEY").is_none());
1539 assert_eq!(val["env"]["OTHER_KEY"], "keep");
1540 assert_eq!(val["extra"], true);
1541 }
1542
1543 #[test]
1544 fn remove_env_vars_false_when_file_missing() {
1545 let (_tmp, path) = temp_settings_path();
1546 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1547 assert!(!path.exists());
1548 }
1549
1550 #[test]
1551 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1552 let (_tmp, path) = temp_settings_path();
1553 fs::create_dir_all(path.parent().unwrap()).unwrap();
1554
1555 fs::write(&path, r#"{"extra": true}"#).unwrap();
1557 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1558
1559 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1561 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1562 }
1563
1564 #[test]
1565 fn upsert_env_vars_bare_filename_skips_dir_creation() {
1566 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1569 let path = Path::new(&name);
1570
1571 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1572
1573 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1574 fs::remove_file(path).unwrap();
1575 }
1576
1577 #[test]
1578 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1579 let (_tmp, path) = temp_settings_path();
1580 fs::create_dir_all(path.parent().unwrap()).unwrap();
1581 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1582 fs::write(&path, original).unwrap();
1583
1584 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1585 assert!(!removed);
1586 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1588 }
1589
1590 #[test]
1593 fn upsert_env_vars_in_profile_creates_profile_env() {
1594 let (_tmp, path) = temp_settings_path();
1595
1596 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1597
1598 let val = read_json(&path);
1599 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1600 assert!(val.get("env").is_none());
1602
1603 #[cfg(unix)]
1606 {
1607 use std::os::unix::fs::PermissionsExt;
1608 let dir_mode = fs::metadata(path.parent().unwrap())
1609 .unwrap()
1610 .permissions()
1611 .mode();
1612 assert_eq!(dir_mode & 0o777, 0o700);
1613 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1614 assert_eq!(file_mode & 0o777, 0o600);
1615 }
1616 }
1617
1618 #[test]
1619 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1620 let (_tmp, path) = temp_settings_path();
1621 fs::create_dir_all(path.parent().unwrap()).unwrap();
1622 fs::write(
1623 &path,
1624 r#"{
1625 "env": {"SHARED": "base"},
1626 "profiles": {
1627 "work": {"env": {"OLD": "keep"}},
1628 "home": {"env": {"SHARED": "home"}}
1629 },
1630 "extra": true
1631 }"#,
1632 )
1633 .unwrap();
1634
1635 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1636
1637 let val = read_json(&path);
1638 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1639 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1640 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1641 assert_eq!(val["env"]["SHARED"], "base");
1642 assert_eq!(val["extra"], true);
1643 }
1644
1645 #[test]
1646 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1647 let (_tmp, path) = temp_settings_path();
1648 fs::create_dir_all(path.parent().unwrap()).unwrap();
1649
1650 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1652 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1653 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1654
1655 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1657 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1658 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1659 }
1660
1661 #[test]
1662 fn remove_env_vars_in_profile_removes_only_profile_keys() {
1663 let (_tmp, path) = temp_settings_path();
1664 fs::create_dir_all(path.parent().unwrap()).unwrap();
1665 fs::write(
1666 &path,
1667 r#"{
1668 "env": {"A_KEY": "base"},
1669 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1670 }"#,
1671 )
1672 .unwrap();
1673
1674 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1675 assert!(removed);
1676
1677 let val = read_json(&path);
1678 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1679 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1680 assert_eq!(val["env"]["A_KEY"], "base");
1682 }
1683
1684 #[test]
1685 fn remove_env_vars_in_profile_false_when_profile_missing() {
1686 let (_tmp, path) = temp_settings_path();
1687 fs::create_dir_all(path.parent().unwrap()).unwrap();
1688 let original = r#"{"env": {"A_KEY": "base"}}"#;
1689 fs::write(&path, original).unwrap();
1690
1691 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1692 assert!(!removed);
1693 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1695 }
1696
1697 #[test]
1698 fn remove_env_vars_in_none_targets_base_env() {
1699 let (_tmp, path) = temp_settings_path();
1700 fs::create_dir_all(path.parent().unwrap()).unwrap();
1701 fs::write(
1702 &path,
1703 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1704 )
1705 .unwrap();
1706
1707 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1708 assert!(removed);
1709
1710 let val = read_json(&path);
1711 assert!(val["env"].get("A_KEY").is_none());
1712 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1713 }
1714
1715 #[test]
1718 fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
1719 let mut root = serde_json::json!({});
1720 {
1721 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1722 map.insert("client_id".to_string(), serde_json::json!("id"));
1723 }
1724 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1725 }
1726
1727 #[test]
1728 fn ensure_object_at_replaces_non_object_nodes_along_path() {
1729 let mut root = serde_json::json!({"gmail": "bogus"});
1730 {
1731 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1732 map.insert("client_id".to_string(), serde_json::json!("id"));
1733 }
1734 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1735 }
1736
1737 #[test]
1738 fn object_at_mut_none_when_any_segment_absent() {
1739 let mut root = serde_json::json!({"gmail": {"accounts": {}}});
1740 assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
1741 assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
1742 }
1743
1744 #[test]
1747 fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
1748 let (_tmp, path) = temp_settings_path();
1749 fs::create_dir_all(path.parent().unwrap()).unwrap();
1750 fs::write(
1751 &path,
1752 r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1753 )
1754 .unwrap();
1755
1756 Settings::upsert_gmail_account(
1757 &path,
1758 "work",
1759 &[
1760 ("client_id", serde_json::Value::String("id".to_string())),
1761 (
1762 "refresh_token",
1763 serde_json::Value::String("token".to_string()),
1764 ),
1765 ],
1766 )
1767 .unwrap();
1768
1769 let val = read_json(&path);
1770 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
1771 assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
1772 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1773 assert_eq!(val["env"]["SHARED"], "base");
1774 assert_eq!(val["extra"], true);
1775
1776 #[cfg(unix)]
1777 {
1778 use std::os::unix::fs::PermissionsExt;
1779 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1780 assert_eq!(file_mode & 0o777, 0o600);
1781 }
1782 }
1783
1784 #[test]
1791 fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1792 let (_tmp, path) = temp_settings_path();
1793
1794 Settings::upsert_gmail_account(
1795 &path,
1796 "work",
1797 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1798 )
1799 .unwrap();
1800
1801 let val = read_json(&path);
1802 assert_eq!(
1803 val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
1804 true
1805 );
1806
1807 let settings = Settings::load_from_path(&path).unwrap();
1808 assert!(
1809 settings.gmail.accounts["work"].chrome_profile_from_email,
1810 "the bool field must deserialize back to `true`, not the string \"true\""
1811 );
1812 }
1813
1814 #[test]
1815 fn remove_gmail_account_true_when_present_false_when_absent() {
1816 let (_tmp, path) = temp_settings_path();
1817 fs::create_dir_all(path.parent().unwrap()).unwrap();
1818 fs::write(
1819 &path,
1820 r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1821 )
1822 .unwrap();
1823
1824 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1825 let val = read_json(&path);
1826 assert!(val["gmail"]["accounts"].get("work").is_none());
1827 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1828
1829 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1830 }
1831
1832 #[test]
1833 fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
1834 let (_tmp, path) = temp_settings_path();
1835 fs::create_dir_all(path.parent().unwrap()).unwrap();
1836 fs::write(
1837 &path,
1838 r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1839 )
1840 .unwrap();
1841
1842 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1843 let val = read_json(&path);
1844 assert!(val["gmail"].get("default_account").is_none());
1845 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1846 }
1847
1848 #[test]
1849 fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
1850 let (_tmp, path) = temp_settings_path();
1851 fs::create_dir_all(path.parent().unwrap()).unwrap();
1852 fs::write(
1853 &path,
1854 r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1855 )
1856 .unwrap();
1857
1858 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1859 let val = read_json(&path);
1860 assert_eq!(val["gmail"]["default_account"], "personal");
1861 }
1862
1863 #[test]
1864 fn remove_gmail_account_false_when_file_missing() {
1865 let (_tmp, path) = temp_settings_path();
1866 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1867 assert!(!path.exists());
1868 }
1869
1870 #[test]
1871 fn set_gmail_default_account_sets_and_clears() {
1872 let (_tmp, path) = temp_settings_path();
1873
1874 Settings::set_gmail_default_account(&path, Some("work")).unwrap();
1875 assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
1876
1877 Settings::set_gmail_default_account(&path, None).unwrap();
1878 assert!(read_json(&path)["gmail"].get("default_account").is_none());
1879 }
1880
1881 #[test]
1884 fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
1885 let (_tmp, path) = temp_settings_path();
1886 fs::create_dir_all(path.parent().unwrap()).unwrap();
1887 fs::write(
1888 &path,
1889 r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1890 )
1891 .unwrap();
1892
1893 Settings::upsert_drive_account(
1894 &path,
1895 "work",
1896 &[
1897 ("client_id", serde_json::Value::String("id".to_string())),
1898 (
1899 "refresh_token",
1900 serde_json::Value::String("token".to_string()),
1901 ),
1902 ],
1903 )
1904 .unwrap();
1905
1906 let val = read_json(&path);
1907 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
1908 assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
1909 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1910 assert_eq!(val["env"]["SHARED"], "base");
1911 assert_eq!(val["extra"], true);
1912
1913 #[cfg(unix)]
1914 {
1915 use std::os::unix::fs::PermissionsExt;
1916 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1917 assert_eq!(file_mode & 0o777, 0o600);
1918 }
1919 }
1920
1921 #[test]
1926 fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1927 let (_tmp, path) = temp_settings_path();
1928
1929 Settings::upsert_drive_account(
1930 &path,
1931 "work",
1932 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1933 )
1934 .unwrap();
1935
1936 let val = read_json(&path);
1937 assert_eq!(
1938 val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
1939 true
1940 );
1941
1942 let settings = Settings::load_from_path(&path).unwrap();
1943 assert!(
1944 settings.drive.accounts["work"].chrome_profile_from_email,
1945 "the bool field must deserialize back to `true`, not the string \"true\""
1946 );
1947 }
1948
1949 #[test]
1950 fn remove_drive_account_true_when_present_false_when_absent() {
1951 let (_tmp, path) = temp_settings_path();
1952 fs::create_dir_all(path.parent().unwrap()).unwrap();
1953 fs::write(
1954 &path,
1955 r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1956 )
1957 .unwrap();
1958
1959 assert!(Settings::remove_drive_account(&path, "work").unwrap());
1960 let val = read_json(&path);
1961 assert!(val["drive"]["accounts"].get("work").is_none());
1962 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1963
1964 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
1965 }
1966
1967 #[test]
1968 fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
1969 let (_tmp, path) = temp_settings_path();
1970 fs::create_dir_all(path.parent().unwrap()).unwrap();
1971 fs::write(
1972 &path,
1973 r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1974 )
1975 .unwrap();
1976
1977 assert!(Settings::remove_drive_account(&path, "work").unwrap());
1978 let val = read_json(&path);
1979 assert!(val["drive"].get("default_account").is_none());
1980 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1981 }
1982
1983 #[test]
1984 fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
1985 let (_tmp, path) = temp_settings_path();
1986 fs::create_dir_all(path.parent().unwrap()).unwrap();
1987 fs::write(
1988 &path,
1989 r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1990 )
1991 .unwrap();
1992
1993 assert!(Settings::remove_drive_account(&path, "work").unwrap());
1994 let val = read_json(&path);
1995 assert_eq!(val["drive"]["default_account"], "personal");
1996 }
1997
1998 #[test]
1999 fn remove_drive_account_false_when_file_missing() {
2000 let (_tmp, path) = temp_settings_path();
2001 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2002 assert!(!path.exists());
2003 }
2004
2005 #[test]
2006 fn set_drive_default_account_sets_and_clears() {
2007 let (_tmp, path) = temp_settings_path();
2008
2009 Settings::set_drive_default_account(&path, Some("work")).unwrap();
2010 assert_eq!(read_json(&path)["drive"]["default_account"], "work");
2011
2012 Settings::set_drive_default_account(&path, None).unwrap();
2013 assert!(read_json(&path)["drive"].get("default_account").is_none());
2014 }
2015}