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)]
236 pub scope: Option<String>,
237 #[serde(default)]
243 pub email_address: Option<String>,
244
245 #[serde(default)]
255 pub chrome_profile_from_email: bool,
256
257 #[serde(default)]
265 pub browser_command: Option<String>,
266
267 #[serde(default)]
280 pub write_permissions: WritePermissionsSettings,
281}
282
283#[derive(Debug, Default, Deserialize)]
286pub struct WritePermissionsSettings {
287 #[serde(default)]
290 pub rules: Vec<crate::drive::write_gate::FolderPermissionRule>,
291}
292
293#[derive(Debug, Default, Deserialize)]
301pub struct DriveSettings {
302 #[serde(default)]
306 pub default_account: Option<String>,
307
308 #[serde(default)]
310 pub accounts: HashMap<String, DriveAccountSettings>,
311}
312
313#[derive(Debug, Default, Deserialize)]
315pub struct Settings {
316 #[serde(default)]
319 pub env: HashMap<String, String>,
320
321 #[serde(default)]
324 pub profiles: HashMap<String, Profile>,
325
326 #[serde(default)]
329 pub mcp: McpSettings,
330
331 #[serde(default)]
334 pub gmail: GmailSettings,
335
336 #[serde(default)]
339 pub drive: DriveSettings,
340}
341
342pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
348 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
349}
350
351#[must_use]
355pub fn profile_suffix(profile: Option<&str>) -> String {
356 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
357}
358
359#[derive(Debug, Default)]
370pub struct SettingsEnv {
371 settings: Settings,
372 active_profile: Option<String>,
373}
374
375impl SettingsEnv {
376 pub fn load() -> Self {
380 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
381 }
382
383 pub fn load_with_profile(profile: Option<&str>) -> Self {
387 Self {
388 settings: Settings::load().unwrap_or_default(),
389 active_profile: profile.map(str::to_string),
390 }
391 }
392
393 pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
400 Self {
401 settings,
402 active_profile: profile.map(str::to_string),
403 }
404 }
405}
406
407impl EnvSource for SettingsEnv {
408 fn var(&self, key: &str) -> Option<String> {
409 self.settings
410 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
411 }
412}
413
414impl Settings {
415 pub fn load() -> Result<Self> {
417 let settings_path = Self::get_settings_path()?;
418 Self::load_from_path(&settings_path)
419 }
420
421 pub fn load_mcp() -> McpSettings {
426 Self::load().map(|s| s.mcp).unwrap_or_default()
427 }
428
429 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
431 let path = path.as_ref();
432
433 if !path.exists() {
435 return Ok(Self::default());
436 }
437
438 let content = fs::read_to_string(path)
440 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
441
442 serde_json::from_str::<Self>(&content)
443 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
444 }
445
446 pub fn get_settings_path() -> Result<PathBuf> {
448 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
449
450 Ok(home_dir.join(".omni-dev").join("settings.json"))
451 }
452
453 pub fn get_env_var(&self, key: &str) -> Option<String> {
456 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
457 }
458
459 pub fn resolve_with<E: EnvSource>(
468 &self,
469 raw: &E,
470 active: Option<&str>,
471 key: &str,
472 ) -> Option<String> {
473 self.resolve_with_source(raw, active, key)
474 .map(|(value, _)| value)
475 }
476
477 pub fn resolve_with_source<E: EnvSource>(
485 &self,
486 raw: &E,
487 active: Option<&str>,
488 key: &str,
489 ) -> Option<(String, EnvValueSource)> {
490 if let Some(value) = raw.var(key) {
491 return Some((value, EnvValueSource::ProcessEnv));
492 }
493 match active {
494 Some(name) => self
495 .profiles
496 .get(name)
497 .and_then(|p| p.env.get(key).cloned())
498 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
499 None => self
500 .env
501 .get(key)
502 .cloned()
503 .map(|value| (value, EnvValueSource::SettingsEnv)),
504 }
505 }
506
507 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
511 Self::upsert_env_vars_in(path, None, vars)
512 }
513
514 pub fn upsert_env_vars_in(
529 path: &Path,
530 profile: Option<&str>,
531 vars: &[(&str, &str)],
532 ) -> Result<()> {
533 let mut settings_value = read_or_default_settings(path)?;
534
535 let env = ensure_env_object(&mut settings_value, profile)?;
536 for (key, value) in vars {
537 env.insert(
538 (*key).to_string(),
539 serde_json::Value::String((*value).to_string()),
540 );
541 }
542
543 write_settings(path, &settings_value)
544 }
545
546 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
549 Self::remove_env_vars_in(path, None, keys)
550 }
551
552 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
563 if !path.exists() {
564 return Ok(false);
565 }
566 let mut settings_value = read_or_default_settings(path)?;
567
568 let mut removed = false;
569 if let Some(env) = env_object_mut(&mut settings_value, profile) {
570 for key in keys {
571 if env.remove(*key).is_some() {
572 removed = true;
573 }
574 }
575 }
576
577 if removed {
578 write_settings(path, &settings_value)?;
579 }
580 Ok(removed)
581 }
582
583 pub fn validate_profile(&self, name: &str) -> Result<()> {
587 if self.profiles.contains_key(name) {
588 return Ok(());
589 }
590 let known = if self.profiles.is_empty() {
591 "(none)".to_string()
592 } else {
593 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
594 names.sort_unstable();
595 names.join(", ")
596 };
597 Err(anyhow::anyhow!(
598 "unknown profile '{name}'; known profiles: {known}"
599 ))
600 }
601
602 pub fn upsert_gmail_account(
618 path: &Path,
619 account: &str,
620 vars: &[(&str, serde_json::Value)],
621 ) -> Result<()> {
622 let mut settings_value = read_or_default_settings(path)?;
623
624 let entry = ensure_object_at(&mut settings_value, &["gmail", "accounts", account])?;
625 for (key, value) in vars {
626 entry.insert((*key).to_string(), value.clone());
627 }
628
629 write_settings(path, &settings_value)
630 }
631
632 pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
643 if !path.exists() {
644 return Ok(false);
645 }
646 let mut settings_value = read_or_default_settings(path)?;
647
648 let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
649 .is_some_and(|accounts| accounts.remove(account).is_some());
650
651 if removed {
652 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
653 if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
654 gmail.remove("default_account");
655 }
656 }
657 write_settings(path, &settings_value)?;
658 }
659 Ok(removed)
660 }
661
662 pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
668 let mut settings_value = read_or_default_settings(path)?;
669
670 match account {
671 Some(name) => {
672 let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
673 gmail.insert(
674 "default_account".to_string(),
675 serde_json::Value::String(name.to_string()),
676 );
677 }
678 None => {
679 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
680 gmail.remove("default_account");
681 }
682 }
683 }
684
685 write_settings(path, &settings_value)
686 }
687
688 pub fn upsert_drive_account(
700 path: &Path,
701 account: &str,
702 vars: &[(&str, serde_json::Value)],
703 ) -> Result<()> {
704 let mut settings_value = read_or_default_settings(path)?;
705
706 let entry = ensure_object_at(&mut settings_value, &["drive", "accounts", account])?;
707 for (key, value) in vars {
708 entry.insert((*key).to_string(), value.clone());
709 }
710
711 write_settings(path, &settings_value)
712 }
713
714 pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
724 if !path.exists() {
725 return Ok(false);
726 }
727 let mut settings_value = read_or_default_settings(path)?;
728
729 let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
730 .is_some_and(|accounts| accounts.remove(account).is_some());
731
732 if removed {
733 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
734 if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
735 drive.remove("default_account");
736 }
737 }
738 write_settings(path, &settings_value)?;
739 }
740 Ok(removed)
741 }
742
743 pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
748 let mut settings_value = read_or_default_settings(path)?;
749
750 match account {
751 Some(name) => {
752 let drive = ensure_object_at(&mut settings_value, &["drive"])?;
753 drive.insert(
754 "default_account".to_string(),
755 serde_json::Value::String(name.to_string()),
756 );
757 }
758 None => {
759 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
760 drive.remove("default_account");
761 }
762 }
763 }
764
765 write_settings(path, &settings_value)
766 }
767}
768
769fn ensure_env_object<'a>(
773 root: &'a mut serde_json::Value,
774 profile: Option<&str>,
775) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
776 match profile {
777 Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
778 None => ensure_object_at(root, &["env"]),
779 }
780}
781
782fn env_object_mut<'a>(
786 root: &'a mut serde_json::Value,
787 profile: Option<&str>,
788) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
789 match profile {
790 Some(name) => object_at_mut(root, &["profiles", name, "env"]),
791 None => object_at_mut(root, &["env"]),
792 }
793}
794
795fn ensure_object_at<'a>(
803 root: &'a mut serde_json::Value,
804 segments: &[&str],
805) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
806 let mut current = root;
807 for segment in segments {
808 if !current
809 .get(*segment)
810 .is_some_and(serde_json::Value::is_object)
811 {
812 current[*segment] = serde_json::json!({});
813 }
814 current = current
815 .get_mut(*segment)
816 .context("Internal error: target key missing immediately after being created")?;
817 }
818 current
819 .as_object_mut()
820 .context("Internal error: target key is not an object after initialization")
821}
822
823fn object_at_mut<'a>(
827 root: &'a mut serde_json::Value,
828 segments: &[&str],
829) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
830 let mut current = root;
831 for segment in segments {
832 current = current.get_mut(*segment)?;
833 }
834 current.as_object_mut()
835}
836
837fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
840 if path.exists() {
841 let content = fs::read_to_string(path)
842 .with_context(|| format!("Failed to read {}", path.display()))?;
843 serde_json::from_str(&content)
844 .with_context(|| format!("Failed to parse {}", path.display()))
845 } else {
846 Ok(serde_json::json!({}))
847 }
848}
849
850fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
855 if let Some(parent) = path.parent() {
856 if !parent.as_os_str().is_empty() {
857 crate::daemon::paths::ensure_dir_0700(parent)?;
858 }
859 }
860 let formatted =
861 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
862 write_file_0600(path, &formatted)
863 .with_context(|| format!("Failed to write {}", path.display()))?;
864 crate::daemon::paths::set_file_0600(path)?;
865 Ok(())
866}
867
868#[cfg(unix)]
870fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
871 use std::io::Write;
872 use std::os::unix::fs::OpenOptionsExt;
873
874 let mut file = fs::OpenOptions::new()
875 .write(true)
876 .create(true)
877 .truncate(true)
878 .mode(0o600)
879 .open(path)?;
880 file.write_all(contents.as_bytes())
881}
882
883#[cfg(not(unix))]
886fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
887 fs::write(path, contents)
888}
889
890pub fn get_env_var(key: &str) -> Result<String> {
893 get_env_var_with(&SystemEnv, Settings::load, key)
894}
895
896pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
903 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
904}
905
906fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
909where
910 E: EnvSource,
911 F: FnOnce() -> Result<Settings>,
912{
913 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
914}
915
916fn get_env_var_sourced_with<E, F>(
924 env: &E,
925 load: F,
926 from_cli_flag: bool,
927 key: &str,
928) -> Result<(String, EnvValueSource)>
929where
930 E: EnvSource,
931 F: FnOnce() -> Result<Settings>,
932{
933 if let Some(value) = env.var(key) {
937 let source = if from_cli_flag {
938 EnvValueSource::CliFlag
939 } else {
940 EnvValueSource::ProcessEnv
941 };
942 return Ok((value, source));
943 }
944 match load() {
945 Ok(settings) => settings
946 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
947 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
948 Err(err) => {
949 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
951 }
952 }
953}
954
955pub fn get_env_vars(keys: &[&str]) -> Result<String> {
957 for key in keys {
958 if let Ok(value) = get_env_var(key) {
959 return Ok(value);
960 }
961 }
962
963 Err(anyhow::anyhow!(
964 "None of the environment variables found: {keys:?}"
965 ))
966}
967
968#[cfg(test)]
969#[allow(clippy::unwrap_used, clippy::expect_used)]
970mod tests {
971 use super::*;
972 use crate::test_support::env::MapEnv;
973 use std::env;
974 use std::fs;
975 use tempfile::TempDir;
976
977 fn settings_with_profile() -> Settings {
980 let mut base = HashMap::new();
981 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
982 base.insert("SHARED".to_string(), "base-shared".to_string());
983
984 let mut work_env = HashMap::new();
985 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
986
987 let mut profiles = HashMap::new();
988 profiles.insert("work".to_string(), Profile { env: work_env });
989
990 Settings {
991 env: base,
992 profiles,
993 ..Settings::default()
994 }
995 }
996
997 #[test]
998 fn settings_load_from_path() {
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 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
1020 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
1021 }
1022
1023 #[test]
1024 fn settings_get_env_var() {
1025 let temp_dir = {
1027 std::fs::create_dir_all("tmp").ok();
1028 TempDir::new_in("tmp").unwrap()
1029 };
1030 let settings_path = temp_dir.path().join("settings.json");
1031
1032 let settings_json = r#"{
1034 "env": {
1035 "TEST_VAR": "test_value",
1036 "CLAUDE_API_KEY": "test_api_key"
1037 }
1038 }"#;
1039 fs::write(&settings_path, settings_json).unwrap();
1040
1041 let settings = Settings::load_from_path(&settings_path).unwrap();
1043
1044 env::set_var("TEST_VAR_ENV", "env_value");
1046
1047 env::set_var("TEST_VAR", "env_override");
1049 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1050
1051 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1054
1055 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1057
1058 env::remove_var("TEST_VAR_ENV");
1060 }
1061
1062 #[test]
1065 fn resolve_no_profile_uses_base_env() {
1066 let settings = settings_with_profile();
1067 let raw = MapEnv::new();
1068 assert_eq!(
1069 settings
1070 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1071 .as_deref(),
1072 Some("base@x.com")
1073 );
1074 }
1075
1076 #[test]
1077 fn resolve_active_profile_uses_profile_env() {
1078 let settings = settings_with_profile();
1079 let raw = MapEnv::new();
1080 assert_eq!(
1081 settings
1082 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1083 .as_deref(),
1084 Some("me@work.com")
1085 );
1086 }
1087
1088 #[test]
1089 fn resolve_active_profile_does_not_consult_base() {
1090 let settings = settings_with_profile();
1093 let raw = MapEnv::new();
1094 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1095 }
1096
1097 #[test]
1098 fn resolve_process_env_wins_over_profile_and_base() {
1099 let settings = settings_with_profile();
1100 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1101 assert_eq!(
1102 settings
1103 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1104 .as_deref(),
1105 Some("cli@x.com")
1106 );
1107 assert_eq!(
1108 settings
1109 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1110 .as_deref(),
1111 Some("cli@x.com")
1112 );
1113 }
1114
1115 #[test]
1116 fn resolve_unknown_active_profile_yields_none() {
1117 let settings = settings_with_profile();
1120 let raw = MapEnv::new();
1121 assert_eq!(
1122 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1123 None
1124 );
1125 }
1126
1127 #[test]
1130 fn resolve_with_source_process_env_is_process_env() {
1131 let settings = settings_with_profile();
1132 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1133 assert_eq!(
1134 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1135 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1136 );
1137 }
1138
1139 #[test]
1140 fn resolve_with_source_base_env_is_settings_env() {
1141 let settings = settings_with_profile();
1142 let raw = MapEnv::new();
1143 assert_eq!(
1144 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1145 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1146 );
1147 }
1148
1149 #[test]
1150 fn resolve_with_source_profile_env_names_profile() {
1151 let settings = settings_with_profile();
1152 let raw = MapEnv::new();
1153 assert_eq!(
1154 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
1155 Some((
1156 "me@work.com".to_string(),
1157 EnvValueSource::SettingsProfile("work".to_string())
1158 ))
1159 );
1160 }
1161
1162 #[test]
1163 fn resolve_with_source_missing_key_is_none() {
1164 let settings = settings_with_profile();
1165 let raw = MapEnv::new();
1166 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
1167 }
1168
1169 #[test]
1170 fn env_value_source_display_names_each_layer() {
1171 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
1172 assert_eq!(
1173 EnvValueSource::ProcessEnv.to_string(),
1174 "process environment variable (e.g. a shell export)"
1175 );
1176 assert_eq!(
1177 EnvValueSource::SettingsEnv.to_string(),
1178 "the env map in $HOME/.omni-dev/settings.json"
1179 );
1180 assert_eq!(
1181 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
1182 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
1183 );
1184 }
1185
1186 #[test]
1187 fn active_profile_from_reads_and_trims_empty() {
1188 assert_eq!(active_profile_from(&MapEnv::new()), None);
1189 assert_eq!(
1190 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
1191 None
1192 );
1193 assert_eq!(
1194 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
1195 Some("work")
1196 );
1197 }
1198
1199 #[test]
1200 fn profile_suffix_names_profile_or_is_empty() {
1201 assert_eq!(profile_suffix(None), "");
1202 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
1203 }
1204
1205 #[test]
1206 fn validate_profile_accepts_known() {
1207 assert!(settings_with_profile().validate_profile("work").is_ok());
1208 }
1209
1210 #[test]
1211 fn validate_profile_rejects_unknown_and_lists_sorted() {
1212 let mut settings = settings_with_profile();
1213 settings
1214 .profiles
1215 .insert("personal".to_string(), Profile::default());
1216 let err = settings.validate_profile("wrok").unwrap_err().to_string();
1217 assert_eq!(
1218 err,
1219 "unknown profile 'wrok'; known profiles: personal, work"
1220 );
1221 }
1222
1223 #[test]
1224 fn validate_profile_reports_none_when_empty() {
1225 let settings = Settings::default();
1226 let err = settings.validate_profile("work").unwrap_err().to_string();
1227 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
1228 }
1229
1230 #[test]
1231 fn settings_parse_profiles_from_json() {
1232 let json = r#"{
1233 "env": { "BASE": "b" },
1234 "profiles": {
1235 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
1236 }
1237 }"#;
1238 let settings: Settings = serde_json::from_str(json).unwrap();
1239 assert_eq!(settings.env.get("BASE").unwrap(), "b");
1240 assert_eq!(
1241 settings
1242 .profiles
1243 .get("work")
1244 .unwrap()
1245 .env
1246 .get("ATLASSIAN_EMAIL")
1247 .unwrap(),
1248 "me@work.com"
1249 );
1250 }
1251
1252 #[test]
1253 fn settings_without_profiles_key_defaults_empty() {
1254 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1255 assert!(settings.profiles.is_empty());
1256 }
1257
1258 #[test]
1259 fn settings_parse_mcp_section_from_json() {
1260 let json = r#"{
1261 "mcp": {
1262 "default_model": "claude-sonnet-4-6",
1263 "log_level": "info",
1264 "max_response_bytes": 204800
1265 }
1266 }"#;
1267 let settings: Settings = serde_json::from_str(json).unwrap();
1268 assert_eq!(
1269 settings.mcp.default_model.as_deref(),
1270 Some("claude-sonnet-4-6")
1271 );
1272 assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
1273 assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
1274 }
1275
1276 #[test]
1277 fn settings_without_mcp_key_defaults_all_none() {
1278 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1281 assert!(settings.mcp.default_model.is_none());
1282 assert!(settings.mcp.log_level.is_none());
1283 assert!(settings.mcp.max_response_bytes.is_none());
1284 }
1285
1286 #[test]
1287 fn settings_mcp_partial_section_leaves_others_none() {
1288 let settings: Settings =
1290 serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
1291 assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
1292 assert!(settings.mcp.default_model.is_none());
1293 assert!(settings.mcp.max_response_bytes.is_none());
1294 }
1295
1296 #[test]
1297 fn settings_parse_gmail_section_from_json() {
1298 let json = r#"{
1299 "gmail": {
1300 "default_account": "work",
1301 "accounts": {
1302 "work": {
1303 "client_id": "id",
1304 "client_secret": "secret",
1305 "refresh_token": "token",
1306 "scope": "https://www.googleapis.com/auth/gmail.modify",
1307 "email_address": "alice@work.com"
1308 }
1309 }
1310 }
1311 }"#;
1312 let settings: Settings = serde_json::from_str(json).unwrap();
1313 assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
1314 let account = settings.gmail.accounts.get("work").unwrap();
1315 assert_eq!(account.client_id.as_deref(), Some("id"));
1316 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1317 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1318 assert_eq!(
1319 account.scope.as_deref(),
1320 Some("https://www.googleapis.com/auth/gmail.modify")
1321 );
1322 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1323 }
1324
1325 #[test]
1326 fn settings_without_gmail_key_defaults_empty() {
1327 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1328 assert!(settings.gmail.default_account.is_none());
1329 assert!(settings.gmail.accounts.is_empty());
1330 }
1331
1332 #[test]
1333 fn settings_parse_drive_section_from_json() {
1334 let json = r#"{
1335 "drive": {
1336 "default_account": "work",
1337 "accounts": {
1338 "work": {
1339 "client_id": "id",
1340 "client_secret": "secret",
1341 "refresh_token": "token",
1342 "scope": "https://www.googleapis.com/auth/drive.readonly",
1343 "email_address": "alice@work.com"
1344 }
1345 }
1346 }
1347 }"#;
1348 let settings: Settings = serde_json::from_str(json).unwrap();
1349 assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
1350 let account = settings.drive.accounts.get("work").unwrap();
1351 assert_eq!(account.client_id.as_deref(), Some("id"));
1352 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1353 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1354 assert_eq!(
1355 account.scope.as_deref(),
1356 Some("https://www.googleapis.com/auth/drive.readonly")
1357 );
1358 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1359 }
1360
1361 #[test]
1362 fn settings_without_drive_key_defaults_empty() {
1363 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1364 assert!(settings.drive.default_account.is_none());
1365 assert!(settings.drive.accounts.is_empty());
1366 }
1367
1368 #[test]
1371 fn get_env_var_with_returns_raw_hit_without_loading() {
1372 let env = MapEnv::new().with("K", "v");
1373 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
1374 assert_eq!(value, "v");
1375 }
1376
1377 #[test]
1378 fn get_env_var_with_falls_back_to_base_settings() {
1379 let settings = settings_with_profile();
1380 let env = MapEnv::new();
1381 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1382 assert_eq!(value, "base@x.com");
1383 }
1384
1385 #[test]
1386 fn get_env_var_with_honours_active_profile() {
1387 let settings = settings_with_profile();
1388 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1389 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1390 assert_eq!(value, "me@work.com");
1391 }
1392
1393 #[test]
1394 fn get_env_var_with_missing_key_is_not_found() {
1395 let env = MapEnv::new();
1396 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
1397 .unwrap_err()
1398 .to_string();
1399 assert!(err.contains("Environment variable not found: MISSING"));
1400 }
1401
1402 #[test]
1403 fn get_env_var_with_load_error_maps_to_not_found() {
1404 let env = MapEnv::new();
1405 let err =
1406 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
1407 assert_eq!(err.to_string(), "disk boom");
1410 let chain = format!("{err:#}");
1411 assert!(chain.contains("Environment variable not found: MISSING"));
1412 }
1413
1414 #[test]
1417 fn get_env_var_sourced_with_raw_hit_is_process_env() {
1418 let env = MapEnv::new().with("K", "v");
1419 let resolved =
1420 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
1421 .unwrap();
1422 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
1423 }
1424
1425 #[test]
1426 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
1427 let env = MapEnv::new().with("K", "true");
1428 let resolved =
1429 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
1430 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
1431 }
1432
1433 #[test]
1434 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
1435 let settings = settings_with_profile();
1436 let env = MapEnv::new();
1437 let resolved =
1438 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1439 assert_eq!(
1440 resolved,
1441 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
1442 );
1443
1444 let settings = settings_with_profile();
1445 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1446 let resolved =
1447 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
1448 assert_eq!(
1449 resolved,
1450 (
1451 "me@work.com".to_string(),
1452 EnvValueSource::SettingsProfile("work".to_string())
1453 )
1454 );
1455 }
1456
1457 #[test]
1458 fn cli_flag_export_registry_roundtrip() {
1459 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
1462 assert!(!exported_by_cli_flag(KEY));
1463 note_cli_flag_export(KEY);
1464 assert!(exported_by_cli_flag(KEY));
1465 }
1466
1467 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
1472 let temp_dir = {
1473 std::fs::create_dir_all("tmp").ok();
1474 TempDir::new_in("tmp").unwrap()
1475 };
1476 let path = temp_dir.path().join(".omni-dev").join("settings.json");
1477 (temp_dir, path)
1478 }
1479
1480 fn read_json(path: &Path) -> serde_json::Value {
1481 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
1482 }
1483
1484 #[test]
1485 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
1486 let (_tmp, path) = temp_settings_path();
1487
1488 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
1489
1490 let val = read_json(&path);
1491 assert_eq!(val["env"]["A_KEY"], "a");
1492 assert_eq!(val["env"]["B_KEY"], "b");
1493
1494 #[cfg(unix)]
1496 {
1497 use std::os::unix::fs::PermissionsExt;
1498 let dir_mode = fs::metadata(path.parent().unwrap())
1499 .unwrap()
1500 .permissions()
1501 .mode();
1502 assert_eq!(dir_mode & 0o777, 0o700);
1503 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1504 assert_eq!(file_mode & 0o777, 0o600);
1505 }
1506 }
1507
1508 #[test]
1509 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
1510 let (_tmp, path) = temp_settings_path();
1511 fs::create_dir_all(path.parent().unwrap()).unwrap();
1512 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
1513
1514 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
1515
1516 let val = read_json(&path);
1517 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
1518 assert_eq!(val["extra"], true);
1519 assert_eq!(val["env"]["A_KEY"], "new");
1520 }
1521
1522 #[test]
1523 fn upsert_env_vars_replaces_non_object_env() {
1524 let (_tmp, path) = temp_settings_path();
1525 fs::create_dir_all(path.parent().unwrap()).unwrap();
1526 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1527
1528 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1529
1530 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn upsert_env_vars_retightens_loose_permissions() {
1536 use std::os::unix::fs::PermissionsExt;
1537
1538 let (_tmp, path) = temp_settings_path();
1539 fs::create_dir_all(path.parent().unwrap()).unwrap();
1540 fs::write(&path, r#"{"env": {}}"#).unwrap();
1541 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1542
1543 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
1544
1545 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1546 assert_eq!(file_mode & 0o777, 0o600);
1547 }
1548
1549 #[test]
1550 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
1551 let (_tmp, path) = temp_settings_path();
1552 fs::create_dir_all(path.parent().unwrap()).unwrap();
1553 fs::write(
1554 &path,
1555 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
1556 )
1557 .unwrap();
1558
1559 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
1560 assert!(removed);
1561
1562 let val = read_json(&path);
1563 assert!(val["env"].get("A_KEY").is_none());
1564 assert!(val["env"].get("B_KEY").is_none());
1565 assert_eq!(val["env"]["OTHER_KEY"], "keep");
1566 assert_eq!(val["extra"], true);
1567 }
1568
1569 #[test]
1570 fn remove_env_vars_false_when_file_missing() {
1571 let (_tmp, path) = temp_settings_path();
1572 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1573 assert!(!path.exists());
1574 }
1575
1576 #[test]
1577 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
1578 let (_tmp, path) = temp_settings_path();
1579 fs::create_dir_all(path.parent().unwrap()).unwrap();
1580
1581 fs::write(&path, r#"{"extra": true}"#).unwrap();
1583 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1584
1585 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
1587 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
1588 }
1589
1590 #[test]
1591 fn upsert_env_vars_bare_filename_skips_dir_creation() {
1592 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
1595 let path = Path::new(&name);
1596
1597 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
1598
1599 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
1600 fs::remove_file(path).unwrap();
1601 }
1602
1603 #[test]
1604 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
1605 let (_tmp, path) = temp_settings_path();
1606 fs::create_dir_all(path.parent().unwrap()).unwrap();
1607 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
1608 fs::write(&path, original).unwrap();
1609
1610 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
1611 assert!(!removed);
1612 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1614 }
1615
1616 #[test]
1619 fn upsert_env_vars_in_profile_creates_profile_env() {
1620 let (_tmp, path) = temp_settings_path();
1621
1622 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1623
1624 let val = read_json(&path);
1625 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1626 assert!(val.get("env").is_none());
1628
1629 #[cfg(unix)]
1632 {
1633 use std::os::unix::fs::PermissionsExt;
1634 let dir_mode = fs::metadata(path.parent().unwrap())
1635 .unwrap()
1636 .permissions()
1637 .mode();
1638 assert_eq!(dir_mode & 0o777, 0o700);
1639 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1640 assert_eq!(file_mode & 0o777, 0o600);
1641 }
1642 }
1643
1644 #[test]
1645 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
1646 let (_tmp, path) = temp_settings_path();
1647 fs::create_dir_all(path.parent().unwrap()).unwrap();
1648 fs::write(
1649 &path,
1650 r#"{
1651 "env": {"SHARED": "base"},
1652 "profiles": {
1653 "work": {"env": {"OLD": "keep"}},
1654 "home": {"env": {"SHARED": "home"}}
1655 },
1656 "extra": true
1657 }"#,
1658 )
1659 .unwrap();
1660
1661 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1662
1663 let val = read_json(&path);
1664 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
1665 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
1666 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
1667 assert_eq!(val["env"]["SHARED"], "base");
1668 assert_eq!(val["extra"], true);
1669 }
1670
1671 #[test]
1672 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
1673 let (_tmp, path) = temp_settings_path();
1674 fs::create_dir_all(path.parent().unwrap()).unwrap();
1675
1676 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
1678 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1679 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1680
1681 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
1683 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
1684 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
1685 }
1686
1687 #[test]
1688 fn remove_env_vars_in_profile_removes_only_profile_keys() {
1689 let (_tmp, path) = temp_settings_path();
1690 fs::create_dir_all(path.parent().unwrap()).unwrap();
1691 fs::write(
1692 &path,
1693 r#"{
1694 "env": {"A_KEY": "base"},
1695 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
1696 }"#,
1697 )
1698 .unwrap();
1699
1700 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1701 assert!(removed);
1702
1703 let val = read_json(&path);
1704 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
1705 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
1706 assert_eq!(val["env"]["A_KEY"], "base");
1708 }
1709
1710 #[test]
1711 fn remove_env_vars_in_profile_false_when_profile_missing() {
1712 let (_tmp, path) = temp_settings_path();
1713 fs::create_dir_all(path.parent().unwrap()).unwrap();
1714 let original = r#"{"env": {"A_KEY": "base"}}"#;
1715 fs::write(&path, original).unwrap();
1716
1717 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
1718 assert!(!removed);
1719 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1721 }
1722
1723 #[test]
1724 fn remove_env_vars_in_none_targets_base_env() {
1725 let (_tmp, path) = temp_settings_path();
1726 fs::create_dir_all(path.parent().unwrap()).unwrap();
1727 fs::write(
1728 &path,
1729 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
1730 )
1731 .unwrap();
1732
1733 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
1734 assert!(removed);
1735
1736 let val = read_json(&path);
1737 assert!(val["env"].get("A_KEY").is_none());
1738 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
1739 }
1740
1741 #[test]
1744 fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
1745 let mut root = serde_json::json!({});
1746 {
1747 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1748 map.insert("client_id".to_string(), serde_json::json!("id"));
1749 }
1750 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1751 }
1752
1753 #[test]
1754 fn ensure_object_at_replaces_non_object_nodes_along_path() {
1755 let mut root = serde_json::json!({"gmail": "bogus"});
1756 {
1757 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
1758 map.insert("client_id".to_string(), serde_json::json!("id"));
1759 }
1760 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
1761 }
1762
1763 #[test]
1764 fn object_at_mut_none_when_any_segment_absent() {
1765 let mut root = serde_json::json!({"gmail": {"accounts": {}}});
1766 assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
1767 assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
1768 }
1769
1770 #[test]
1773 fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
1774 let (_tmp, path) = temp_settings_path();
1775 fs::create_dir_all(path.parent().unwrap()).unwrap();
1776 fs::write(
1777 &path,
1778 r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1779 )
1780 .unwrap();
1781
1782 Settings::upsert_gmail_account(
1783 &path,
1784 "work",
1785 &[
1786 ("client_id", serde_json::Value::String("id".to_string())),
1787 (
1788 "refresh_token",
1789 serde_json::Value::String("token".to_string()),
1790 ),
1791 ],
1792 )
1793 .unwrap();
1794
1795 let val = read_json(&path);
1796 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
1797 assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
1798 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1799 assert_eq!(val["env"]["SHARED"], "base");
1800 assert_eq!(val["extra"], true);
1801
1802 #[cfg(unix)]
1803 {
1804 use std::os::unix::fs::PermissionsExt;
1805 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1806 assert_eq!(file_mode & 0o777, 0o600);
1807 }
1808 }
1809
1810 #[test]
1817 fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1818 let (_tmp, path) = temp_settings_path();
1819
1820 Settings::upsert_gmail_account(
1821 &path,
1822 "work",
1823 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1824 )
1825 .unwrap();
1826
1827 let val = read_json(&path);
1828 assert_eq!(
1829 val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
1830 true
1831 );
1832
1833 let settings = Settings::load_from_path(&path).unwrap();
1834 assert!(
1835 settings.gmail.accounts["work"].chrome_profile_from_email,
1836 "the bool field must deserialize back to `true`, not the string \"true\""
1837 );
1838 }
1839
1840 #[test]
1841 fn remove_gmail_account_true_when_present_false_when_absent() {
1842 let (_tmp, path) = temp_settings_path();
1843 fs::create_dir_all(path.parent().unwrap()).unwrap();
1844 fs::write(
1845 &path,
1846 r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1847 )
1848 .unwrap();
1849
1850 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1851 let val = read_json(&path);
1852 assert!(val["gmail"]["accounts"].get("work").is_none());
1853 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1854
1855 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1856 }
1857
1858 #[test]
1859 fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
1860 let (_tmp, path) = temp_settings_path();
1861 fs::create_dir_all(path.parent().unwrap()).unwrap();
1862 fs::write(
1863 &path,
1864 r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1865 )
1866 .unwrap();
1867
1868 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1869 let val = read_json(&path);
1870 assert!(val["gmail"].get("default_account").is_none());
1871 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
1872 }
1873
1874 #[test]
1875 fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
1876 let (_tmp, path) = temp_settings_path();
1877 fs::create_dir_all(path.parent().unwrap()).unwrap();
1878 fs::write(
1879 &path,
1880 r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
1881 )
1882 .unwrap();
1883
1884 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
1885 let val = read_json(&path);
1886 assert_eq!(val["gmail"]["default_account"], "personal");
1887 }
1888
1889 #[test]
1890 fn remove_gmail_account_false_when_file_missing() {
1891 let (_tmp, path) = temp_settings_path();
1892 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
1893 assert!(!path.exists());
1894 }
1895
1896 #[test]
1897 fn set_gmail_default_account_sets_and_clears() {
1898 let (_tmp, path) = temp_settings_path();
1899
1900 Settings::set_gmail_default_account(&path, Some("work")).unwrap();
1901 assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
1902
1903 Settings::set_gmail_default_account(&path, None).unwrap();
1904 assert!(read_json(&path)["gmail"].get("default_account").is_none());
1905 }
1906
1907 #[test]
1910 fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
1911 let (_tmp, path) = temp_settings_path();
1912 fs::create_dir_all(path.parent().unwrap()).unwrap();
1913 fs::write(
1914 &path,
1915 r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
1916 )
1917 .unwrap();
1918
1919 Settings::upsert_drive_account(
1920 &path,
1921 "work",
1922 &[
1923 ("client_id", serde_json::Value::String("id".to_string())),
1924 (
1925 "refresh_token",
1926 serde_json::Value::String("token".to_string()),
1927 ),
1928 ],
1929 )
1930 .unwrap();
1931
1932 let val = read_json(&path);
1933 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
1934 assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
1935 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
1936 assert_eq!(val["env"]["SHARED"], "base");
1937 assert_eq!(val["extra"], true);
1938
1939 #[cfg(unix)]
1940 {
1941 use std::os::unix::fs::PermissionsExt;
1942 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
1943 assert_eq!(file_mode & 0o777, 0o600);
1944 }
1945 }
1946
1947 #[test]
1952 fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
1953 let (_tmp, path) = temp_settings_path();
1954
1955 Settings::upsert_drive_account(
1956 &path,
1957 "work",
1958 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
1959 )
1960 .unwrap();
1961
1962 let val = read_json(&path);
1963 assert_eq!(
1964 val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
1965 true
1966 );
1967
1968 let settings = Settings::load_from_path(&path).unwrap();
1969 assert!(
1970 settings.drive.accounts["work"].chrome_profile_from_email,
1971 "the bool field must deserialize back to `true`, not the string \"true\""
1972 );
1973 }
1974
1975 #[test]
1978 fn write_permissions_field_round_trips_through_settings_load() {
1979 let (_tmp, path) = temp_settings_path();
1980 fs::create_dir_all(path.parent().unwrap()).unwrap();
1981 fs::write(
1982 &path,
1983 r#"{
1984 "drive": {
1985 "accounts": {
1986 "work": {
1987 "write_permissions": {
1988 "rules": [
1989 {
1990 "folder_id": "folder-1",
1991 "recursive": true,
1992 "allow": ["create", "upload"],
1993 "deny": ["edit"]
1994 }
1995 ]
1996 }
1997 }
1998 }
1999 }
2000 }"#,
2001 )
2002 .unwrap();
2003
2004 let settings = Settings::load_from_path(&path).unwrap();
2005 let rules = &settings.drive.accounts["work"].write_permissions.rules;
2006 assert_eq!(rules.len(), 1);
2007 assert_eq!(rules[0].folder_id, "folder-1");
2008 assert!(rules[0].recursive);
2009 assert!(rules[0]
2010 .allow
2011 .contains(&crate::drive::write_gate::DriveOperation::Create));
2012 assert!(rules[0]
2013 .allow
2014 .contains(&crate::drive::write_gate::DriveOperation::Upload));
2015 assert!(rules[0]
2016 .deny
2017 .contains(&crate::drive::write_gate::DriveOperation::Edit));
2018 }
2019
2020 #[test]
2021 fn write_permissions_absent_defaults_to_empty_rules() {
2022 let (_tmp, path) = temp_settings_path();
2023 fs::create_dir_all(path.parent().unwrap()).unwrap();
2024 fs::write(
2025 &path,
2026 r#"{"drive": {"accounts": {"work": {"client_id": "id"}}}}"#,
2027 )
2028 .unwrap();
2029
2030 let settings = Settings::load_from_path(&path).unwrap();
2031 assert!(settings.drive.accounts["work"]
2032 .write_permissions
2033 .rules
2034 .is_empty());
2035 }
2036
2037 #[test]
2045 fn write_permissions_unknown_future_field_is_preserved_through_read_modify_write() {
2046 let (_tmp, path) = temp_settings_path();
2047 fs::create_dir_all(path.parent().unwrap()).unwrap();
2048 fs::write(
2049 &path,
2050 r#"{
2051 "drive": {
2052 "accounts": {
2053 "work": {
2054 "write_permissions": {
2055 "rules": [],
2056 "future_field": "not yet modeled"
2057 }
2058 }
2059 }
2060 }
2061 }"#,
2062 )
2063 .unwrap();
2064
2065 Settings::upsert_drive_account(
2066 &path,
2067 "work",
2068 &[("client_id", serde_json::Value::String("id".to_string()))],
2069 )
2070 .unwrap();
2071
2072 let val = read_json(&path);
2073 assert_eq!(
2074 val["drive"]["accounts"]["work"]["write_permissions"]["future_field"],
2075 "not yet modeled"
2076 );
2077 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2078 }
2079
2080 #[test]
2081 fn remove_drive_account_true_when_present_false_when_absent() {
2082 let (_tmp, path) = temp_settings_path();
2083 fs::create_dir_all(path.parent().unwrap()).unwrap();
2084 fs::write(
2085 &path,
2086 r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2087 )
2088 .unwrap();
2089
2090 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2091 let val = read_json(&path);
2092 assert!(val["drive"]["accounts"].get("work").is_none());
2093 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2094
2095 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2096 }
2097
2098 #[test]
2099 fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
2100 let (_tmp, path) = temp_settings_path();
2101 fs::create_dir_all(path.parent().unwrap()).unwrap();
2102 fs::write(
2103 &path,
2104 r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2105 )
2106 .unwrap();
2107
2108 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2109 let val = read_json(&path);
2110 assert!(val["drive"].get("default_account").is_none());
2111 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2112 }
2113
2114 #[test]
2115 fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2116 let (_tmp, path) = temp_settings_path();
2117 fs::create_dir_all(path.parent().unwrap()).unwrap();
2118 fs::write(
2119 &path,
2120 r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2121 )
2122 .unwrap();
2123
2124 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2125 let val = read_json(&path);
2126 assert_eq!(val["drive"]["default_account"], "personal");
2127 }
2128
2129 #[test]
2130 fn remove_drive_account_false_when_file_missing() {
2131 let (_tmp, path) = temp_settings_path();
2132 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2133 assert!(!path.exists());
2134 }
2135
2136 #[test]
2137 fn set_drive_default_account_sets_and_clears() {
2138 let (_tmp, path) = temp_settings_path();
2139
2140 Settings::set_drive_default_account(&path, Some("work")).unwrap();
2141 assert_eq!(read_json(&path)["drive"]["default_account"], "work");
2142
2143 Settings::set_drive_default_account(&path, None).unwrap();
2144 assert!(read_json(&path)["drive"].get("default_account").is_none());
2145 }
2146}