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, SecretTriple, SystemEnv};
25use crate::utils::secret_env;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum EnvValueSource {
36 CliFlag,
39 ProcessEnv,
41 SettingsEnv,
43 SettingsProfile(String),
45}
46
47impl fmt::Display for EnvValueSource {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Self::CliFlag => write!(f, "command-line flag"),
51 Self::ProcessEnv => write!(f, "process environment variable (e.g. a shell export)"),
52 Self::SettingsEnv => write!(f, "the env map in $HOME/.omni-dev/settings.json"),
53 Self::SettingsProfile(name) => {
54 write!(
55 f,
56 "the profile '{name}' env map in $HOME/.omni-dev/settings.json"
57 )
58 }
59 }
60 }
61}
62
63static CLI_FLAG_EXPORTS: Mutex<BTreeSet<String>> = Mutex::new(BTreeSet::new());
69
70pub fn note_cli_flag_export(key: &str) {
75 let mut set = CLI_FLAG_EXPORTS
78 .lock()
79 .unwrap_or_else(std::sync::PoisonError::into_inner);
80 set.insert(key.to_string());
81}
82
83#[must_use]
85pub fn exported_by_cli_flag(key: &str) -> bool {
86 CLI_FLAG_EXPORTS
87 .lock()
88 .unwrap_or_else(std::sync::PoisonError::into_inner)
89 .contains(key)
90}
91
92pub const PROFILE_ENV_VAR: &str = "OMNI_DEV_PROFILE";
98
99#[derive(Debug, Default, Deserialize)]
102pub struct Profile {
103 #[serde(default)]
105 pub env: HashMap<String, String>,
106}
107
108#[derive(Debug, Default, Deserialize)]
115pub struct McpSettings {
116 #[serde(default)]
119 pub default_model: Option<String>,
120
121 #[serde(default)]
125 pub log_level: Option<String>,
126
127 #[serde(default)]
131 pub max_response_bytes: Option<usize>,
132}
133
134#[derive(Debug, Default, Deserialize)]
142pub struct GmailAccountSettings {
143 #[serde(default)]
145 pub client_id: Option<String>,
146 #[serde(default)]
148 pub client_secret: Option<String>,
149 #[serde(default)]
152 pub client_secret_file: Option<String>,
153 #[serde(default)]
155 pub refresh_token: Option<String>,
156 #[serde(default)]
160 pub refresh_token_file: Option<String>,
161 #[serde(default)]
163 pub scope: Option<String>,
164 #[serde(default)]
171 pub email_address: Option<String>,
172
173 #[serde(default)]
181 pub chrome_profile_from_email: bool,
182
183 #[serde(default)]
190 pub browser_command: Option<String>,
191}
192
193#[derive(Debug, Default, Deserialize)]
201pub struct GmailSettings {
202 #[serde(default)]
206 pub default_account: Option<String>,
207
208 #[serde(default)]
210 pub accounts: HashMap<String, GmailAccountSettings>,
211}
212
213#[derive(Debug, Default, Deserialize)]
223pub struct DriveAccountSettings {
224 #[serde(default)]
226 pub client_id: Option<String>,
227 #[serde(default)]
229 pub client_secret: Option<String>,
230 #[serde(default)]
233 pub client_secret_file: Option<String>,
234 #[serde(default)]
237 pub refresh_token: Option<String>,
238 #[serde(default)]
242 pub refresh_token_file: Option<String>,
243 #[serde(default)]
255 pub scope: Option<String>,
256 #[serde(default)]
262 pub email_address: Option<String>,
263
264 #[serde(default)]
274 pub chrome_profile_from_email: bool,
275
276 #[serde(default)]
284 pub browser_command: Option<String>,
285
286 #[serde(default)]
295 pub lease_backup_folder_id: Option<String>,
296
297 #[serde(default)]
310 pub write_permissions: WritePermissionsSettings,
311}
312
313#[derive(Debug, Default, Deserialize)]
316pub struct WritePermissionsSettings {
317 #[serde(default)]
320 pub rules: Vec<crate::drive::write_gate::FolderPermissionRule>,
321}
322
323#[derive(Debug, Default, Deserialize)]
331pub struct DriveSettings {
332 #[serde(default)]
336 pub default_account: Option<String>,
337
338 #[serde(default)]
340 pub accounts: HashMap<String, DriveAccountSettings>,
341}
342
343#[derive(Debug, Default, Clone, Deserialize)]
354pub struct LeaseSettings {
355 #[serde(default)]
357 pub default_expiry_minutes: Option<i64>,
358
359 #[serde(default)]
361 pub backup_dir: Option<PathBuf>,
362
363 #[serde(default)]
366 pub biometrics_only: bool,
367
368 #[serde(default)]
372 pub allow_headless: bool,
373}
374
375#[derive(Debug, Default, Deserialize)]
377pub struct DaemonSettings {
378 #[serde(default)]
381 pub log_level: Option<String>,
382}
383
384#[derive(Debug, Default, Deserialize)]
386pub struct Settings {
387 #[serde(default)]
390 pub env: HashMap<String, String>,
391
392 #[serde(default)]
395 pub profiles: HashMap<String, Profile>,
396
397 #[serde(default)]
400 pub mcp: McpSettings,
401
402 #[serde(default)]
404 pub daemon: DaemonSettings,
405
406 #[serde(default)]
409 pub gmail: GmailSettings,
410
411 #[serde(default)]
414 pub drive: DriveSettings,
415
416 #[serde(default)]
419 pub lease: LeaseSettings,
420}
421
422pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
428 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
429}
430
431#[must_use]
435pub fn profile_suffix(profile: Option<&str>) -> String {
436 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
437}
438
439#[derive(Debug, Default)]
450pub struct SettingsEnv {
451 settings: Settings,
452 active_profile: Option<String>,
453}
454
455impl SettingsEnv {
456 pub fn load() -> Self {
461 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
462 }
463
464 pub fn load_with_profile(profile: Option<&str>) -> Self {
468 Self {
469 settings: Settings::load_or_warn_default(),
470 active_profile: profile.map(str::to_string),
471 }
472 }
473
474 pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
481 Self {
482 settings,
483 active_profile: profile.map(str::to_string),
484 }
485 }
486}
487
488impl EnvSource for SettingsEnv {
489 fn var(&self, key: &str) -> Option<String> {
490 self.settings
491 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
492 }
493
494 fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
495 self.settings.resolve_triple_with(
496 &SystemEnv,
497 self.active_profile.as_deref(),
498 key,
499 file_key,
500 command_key,
501 )
502 }
503}
504
505#[derive(Debug)]
509pub struct SettingsEnvRef<'a> {
510 settings: &'a Settings,
511 active_profile: Option<String>,
512}
513
514impl EnvSource for SettingsEnvRef<'_> {
515 fn var(&self, key: &str) -> Option<String> {
516 self.settings
517 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
518 }
519
520 fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
521 self.settings.resolve_triple_with(
522 &SystemEnv,
523 self.active_profile.as_deref(),
524 key,
525 file_key,
526 command_key,
527 )
528 }
529}
530
531struct LoadWarnDedup(Mutex<Option<String>>);
537
538impl LoadWarnDedup {
539 const fn new() -> Self {
540 Self(Mutex::new(None))
541 }
542
543 fn observe(&self, failure: Option<&str>) -> bool {
546 let mut last = self
547 .0
548 .lock()
549 .unwrap_or_else(std::sync::PoisonError::into_inner);
550 match failure {
551 None => {
552 *last = None;
553 false
554 }
555 Some(message) if last.as_deref() == Some(message) => false,
556 Some(message) => {
557 *last = Some(message.to_string());
558 true
559 }
560 }
561 }
562}
563
564static LOAD_WARN_DEDUP: LoadWarnDedup = LoadWarnDedup::new();
565
566fn warn_settings_fallback(dedup: &LoadWarnDedup, message: &str) {
570 if dedup.observe(Some(message)) {
571 tracing::warn!(
572 "{message}; falling back to default settings for this invocation — \
573 any settings.json configuration is being ignored"
574 );
575 }
576}
577
578fn is_secret_env_var(key: &str) -> bool {
580 secret_env::SECRET_ENV_VARS.contains(&key)
581}
582
583fn refuse_secret_commands<'a>(
590 env: &serde_json::Map<String, serde_json::Value>,
591 keys: impl Iterator<Item = &'a str>,
592 profile: Option<&str>,
593) -> Result<()> {
594 for key in keys.filter(|key| is_secret_env_var(key)) {
595 let command_var = secret_env::command_var_name(key);
596 let set = env
597 .get(&command_var)
598 .and_then(serde_json::Value::as_str)
599 .is_some_and(|v| !v.is_empty());
600 if set {
601 let map = match profile {
602 Some(name) => format!("profiles.{name}.env"),
603 None => "env".to_string(),
604 };
605 anyhow::bail!(
606 "{key} is fetched by {command_var} in the {map} map of settings.json, so a login \
607 would replace it with a plaintext value there and nothing was saved; store the \
608 new value in the store {command_var} reads, or remove {command_var} first"
609 );
610 }
611 }
612 Ok(())
613}
614
615impl Settings {
616 pub fn load() -> Result<Self> {
618 let settings_path = Self::get_settings_path()?;
619 Self::load_from_path(&settings_path)
620 }
621
622 pub fn load_or_warn_default() -> Self {
634 match Self::load() {
635 Ok(settings) => {
636 LOAD_WARN_DEDUP.observe(None);
637 settings
638 }
639 Err(e) => {
640 warn_settings_fallback(&LOAD_WARN_DEDUP, &format!("{e:#}"));
641 Self::default()
642 }
643 }
644 }
645
646 pub fn load_mcp() -> McpSettings {
652 Self::load_or_warn_default().mcp
653 }
654
655 pub fn load_daemon() -> DaemonSettings {
657 Self::load_or_warn_default().daemon
658 }
659
660 pub fn warn_bootstrap_failure(message: &str) {
663 warn_settings_fallback(&LOAD_WARN_DEDUP, message);
664 }
665
666 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
668 let path = path.as_ref();
669
670 if !path.exists() {
672 return Ok(Self::default());
673 }
674
675 let content = fs::read_to_string(path)
677 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
678
679 serde_json::from_str::<Self>(&content)
680 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
681 }
682
683 pub fn get_settings_path() -> Result<PathBuf> {
685 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
686
687 Ok(home_dir.join(".omni-dev").join("settings.json"))
688 }
689
690 pub fn get_env_var(&self, key: &str) -> Option<String> {
693 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
694 }
695
696 pub fn resolve_with<E: EnvSource>(
705 &self,
706 raw: &E,
707 active: Option<&str>,
708 key: &str,
709 ) -> Option<String> {
710 self.resolve_with_source(raw, active, key)
711 .map(|(value, _)| value)
712 }
713
714 pub fn resolve_triple_with<E: EnvSource>(
723 &self,
724 raw: &E,
725 active: Option<&str>,
726 key: &str,
727 file_key: &str,
728 command_key: &str,
729 ) -> SecretTriple {
730 let raw_triple = (raw.var(key), raw.var(file_key), raw.var(command_key));
731 if raw_triple.0.is_some() || raw_triple.1.is_some() || raw_triple.2.is_some() {
732 return raw_triple;
733 }
734 let layer = match active {
735 Some(name) => self.profiles.get(name).map(|p| &p.env),
736 None => Some(&self.env),
737 };
738 layer.map_or((None, None, None), |env| {
739 (
740 env.get(key).cloned(),
741 env.get(file_key).cloned(),
742 env.get(command_key).cloned(),
743 )
744 })
745 }
746
747 #[must_use]
751 pub fn env_source(&self) -> SettingsEnvRef<'_> {
752 SettingsEnvRef {
753 settings: self,
754 active_profile: active_profile_from(&SystemEnv),
755 }
756 }
757
758 pub fn resolve_with_source<E: EnvSource>(
766 &self,
767 raw: &E,
768 active: Option<&str>,
769 key: &str,
770 ) -> Option<(String, EnvValueSource)> {
771 if let Some(value) = raw.var(key) {
772 return Some((value, EnvValueSource::ProcessEnv));
773 }
774 match active {
775 Some(name) => self
776 .profiles
777 .get(name)
778 .and_then(|p| p.env.get(key).cloned())
779 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
780 None => self
781 .env
782 .get(key)
783 .cloned()
784 .map(|value| (value, EnvValueSource::SettingsEnv)),
785 }
786 }
787
788 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
792 Self::upsert_env_vars_in(path, None, vars)
793 }
794
795 pub fn upsert_env_vars_in(
810 path: &Path,
811 profile: Option<&str>,
812 vars: &[(&str, &str)],
813 ) -> Result<()> {
814 Self::replace_env_vars_in(path, profile, vars, &[])
815 }
816
817 pub(crate) fn replace_env_vars_in(
822 path: &Path,
823 profile: Option<&str>,
824 vars: &[(&str, &str)],
825 remove: &[&str],
826 ) -> Result<()> {
827 let mut settings_value = read_or_default_settings(path)?;
828
829 let env = ensure_env_object(&mut settings_value, profile)?;
830 refuse_secret_commands(env, vars.iter().map(|(key, _)| *key), profile)?;
831 for key in remove {
832 env.remove(*key);
833 if is_secret_env_var(key) {
834 env.remove(&secret_env::file_var_name(key));
835 env.remove(&secret_env::command_var_name(key));
836 }
837 }
838 for (key, value) in vars {
839 env.insert(
840 (*key).to_string(),
841 serde_json::Value::String((*value).to_string()),
842 );
843 if is_secret_env_var(key) {
847 env.remove(&secret_env::file_var_name(key));
848 }
849 }
850
851 write_settings(path, &settings_value)
852 }
853
854 pub fn ensure_secrets_replaceable(
862 path: &Path,
863 profile: Option<&str>,
864 keys: &[&str],
865 raw: &impl EnvSource,
866 ) -> Result<()> {
867 for key in keys.iter().copied().filter(|key| is_secret_env_var(key)) {
868 let command_var = secret_env::command_var_name(key);
869 if raw.var(&command_var).is_some_and(|v| !v.is_empty()) {
870 anyhow::bail!(
871 "{key} is fetched by {command_var}, which is set in the environment and \
872 would shadow anything a login saved, so nothing was saved; store the new \
873 value in the store {command_var} reads, or unset {command_var} first"
874 );
875 }
876 }
877 if !path.exists() {
878 return Ok(());
879 }
880 let mut settings_value = read_or_default_settings(path)?;
881 match env_object_mut(&mut settings_value, profile) {
882 Some(env) => refuse_secret_commands(env, keys.iter().copied(), profile),
883 None => Ok(()),
884 }
885 }
886
887 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
890 Self::remove_env_vars_in(path, None, keys)
891 }
892
893 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
904 if !path.exists() {
905 return Ok(false);
906 }
907 let mut settings_value = read_or_default_settings(path)?;
908
909 let mut removed = false;
910 if let Some(env) = env_object_mut(&mut settings_value, profile) {
911 for key in keys {
912 if env.remove(*key).is_some() {
913 removed = true;
914 }
915 if is_secret_env_var(key) {
919 for companion in [
920 secret_env::file_var_name(key),
921 secret_env::command_var_name(key),
922 ] {
923 if env.remove(&companion).is_some() {
924 removed = true;
925 }
926 }
927 }
928 }
929 }
930
931 if removed {
932 write_settings(path, &settings_value)?;
933 }
934 Ok(removed)
935 }
936
937 pub fn validate_profile(&self, name: &str) -> Result<()> {
941 if self.profiles.contains_key(name) {
942 return Ok(());
943 }
944 let known = if self.profiles.is_empty() {
945 "(none)".to_string()
946 } else {
947 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
948 names.sort_unstable();
949 names.join(", ")
950 };
951 Err(anyhow::anyhow!(
952 "unknown profile '{name}'; known profiles: {known}"
953 ))
954 }
955
956 pub fn upsert_gmail_account(
972 path: &Path,
973 account: &str,
974 vars: &[(&str, serde_json::Value)],
975 ) -> Result<()> {
976 upsert_account(path, "gmail", account, vars)
977 }
978
979 pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
990 if !path.exists() {
991 return Ok(false);
992 }
993 let mut settings_value = read_or_default_settings(path)?;
994
995 let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
996 .is_some_and(|accounts| accounts.remove(account).is_some());
997
998 if removed {
999 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
1000 if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
1001 gmail.remove("default_account");
1002 }
1003 }
1004 write_settings(path, &settings_value)?;
1005 }
1006 Ok(removed)
1007 }
1008
1009 pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
1015 let mut settings_value = read_or_default_settings(path)?;
1016
1017 match account {
1018 Some(name) => {
1019 let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
1020 gmail.insert(
1021 "default_account".to_string(),
1022 serde_json::Value::String(name.to_string()),
1023 );
1024 }
1025 None => {
1026 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
1027 gmail.remove("default_account");
1028 }
1029 }
1030 }
1031
1032 write_settings(path, &settings_value)
1033 }
1034
1035 pub fn upsert_drive_account(
1047 path: &Path,
1048 account: &str,
1049 vars: &[(&str, serde_json::Value)],
1050 ) -> Result<()> {
1051 upsert_account(path, "drive", account, vars)
1052 }
1053
1054 pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
1064 if !path.exists() {
1065 return Ok(false);
1066 }
1067 let mut settings_value = read_or_default_settings(path)?;
1068
1069 let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
1070 .is_some_and(|accounts| accounts.remove(account).is_some());
1071
1072 if removed {
1073 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
1074 if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
1075 drive.remove("default_account");
1076 }
1077 }
1078 write_settings(path, &settings_value)?;
1079 }
1080 Ok(removed)
1081 }
1082
1083 pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
1088 let mut settings_value = read_or_default_settings(path)?;
1089
1090 match account {
1091 Some(name) => {
1092 let drive = ensure_object_at(&mut settings_value, &["drive"])?;
1093 drive.insert(
1094 "default_account".to_string(),
1095 serde_json::Value::String(name.to_string()),
1096 );
1097 }
1098 None => {
1099 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
1100 drive.remove("default_account");
1101 }
1102 }
1103 }
1104
1105 write_settings(path, &settings_value)
1106 }
1107}
1108
1109fn ensure_env_object<'a>(
1113 root: &'a mut serde_json::Value,
1114 profile: Option<&str>,
1115) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1116 match profile {
1117 Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
1118 None => ensure_object_at(root, &["env"]),
1119 }
1120}
1121
1122fn env_object_mut<'a>(
1126 root: &'a mut serde_json::Value,
1127 profile: Option<&str>,
1128) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1129 match profile {
1130 Some(name) => object_at_mut(root, &["profiles", name, "env"]),
1131 None => object_at_mut(root, &["env"]),
1132 }
1133}
1134
1135fn ensure_object_at<'a>(
1143 root: &'a mut serde_json::Value,
1144 segments: &[&str],
1145) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1146 let mut current = root;
1147 for segment in segments {
1148 if !current
1149 .get(*segment)
1150 .is_some_and(serde_json::Value::is_object)
1151 {
1152 current[*segment] = serde_json::json!({});
1153 }
1154 current = current
1155 .get_mut(*segment)
1156 .context("Internal error: target key missing immediately after being created")?;
1157 }
1158 current
1159 .as_object_mut()
1160 .context("Internal error: target key is not an object after initialization")
1161}
1162
1163fn object_at_mut<'a>(
1167 root: &'a mut serde_json::Value,
1168 segments: &[&str],
1169) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1170 let mut current = root;
1171 for segment in segments {
1172 current = current.get_mut(*segment)?;
1173 }
1174 current.as_object_mut()
1175}
1176
1177fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
1180 if path.exists() {
1181 let content = fs::read_to_string(path)
1182 .with_context(|| format!("Failed to read {}", path.display()))?;
1183 serde_json::from_str(&content)
1184 .with_context(|| format!("Failed to parse {}", path.display()))
1185 } else {
1186 Ok(serde_json::json!({}))
1187 }
1188}
1189
1190const ACCOUNT_SECRET_FIELDS: &[&str] = &["client_secret", "refresh_token"];
1193
1194fn upsert_account(
1211 path: &Path,
1212 section: &str,
1213 account: &str,
1214 vars: &[(&str, serde_json::Value)],
1215) -> Result<()> {
1216 let mut settings_value = read_or_default_settings(path)?;
1217
1218 let entry = ensure_object_at(&mut settings_value, &[section, "accounts", account])?;
1219 let stored_client_id = entry
1220 .get("client_id")
1221 .and_then(serde_json::Value::as_str)
1222 .map(str::to_string);
1223 let mut secret_writes = Vec::new();
1224 for (key, value) in vars {
1225 if let Some(file) = account_secret_file(entry, key, value) {
1226 let label = format!("{section}.accounts.{account}.{key}_file");
1227 let secret = crate::utils::secret::Secret::new(value.as_str().unwrap_or_default());
1228 if let Some(write) =
1229 secret_env::plan_secret_file_write(&label, Path::new(&file), &secret)?
1230 {
1231 if *key == "client_secret" && write.replaces_existing() {
1232 refuse_client_change(&label, stored_client_id.as_deref(), vars)?;
1233 }
1234 secret_writes.push((write, secret));
1235 } entry.remove(*key);
1237 } else {
1238 entry.insert((*key).to_string(), value.clone());
1239 }
1240 }
1241
1242 for (write, secret) in secret_writes {
1243 write.write(&secret)?;
1244 }
1245 write_settings(path, &settings_value)
1246}
1247
1248fn refuse_client_change(
1252 label: &str,
1253 stored: Option<&str>,
1254 vars: &[(&str, serde_json::Value)],
1255) -> Result<()> {
1256 let incoming = vars
1257 .iter()
1258 .find(|(key, _)| *key == "client_id")
1259 .and_then(|(_, value)| value.as_str());
1260 if let (Some(stored), Some(incoming)) = (stored, incoming) {
1261 if stored != incoming {
1262 anyhow::bail!(
1263 "{label} holds the secret of OAuth client {stored}, not {incoming}; refusing to \
1264 replace it, since other accounts may share it. Point {label} at a file of its \
1265 own, or remove it to store the secret in settings.json"
1266 );
1267 }
1268 }
1269 Ok(())
1270}
1271
1272pub(crate) fn account_secret(
1281 section: &str,
1282 account: &str,
1283 field: &str,
1284 value: Option<&str>,
1285 file: Option<&str>,
1286) -> std::result::Result<Option<crate::utils::secret::Secret>, secret_env::SecretEnvError> {
1287 let name = format!("{section}.accounts.{account}.{field}");
1288 secret_env::resolve_secret_pair(&name, value, &format!("{name}_file"), file)
1289}
1290
1291#[cfg(feature = "mcp")]
1295pub(crate) fn account_secret_is_set(value: Option<&str>, file: Option<&str>) -> bool {
1296 value.is_some_and(|v| !v.is_empty()) || file.is_some_and(|f| !f.is_empty())
1297}
1298
1299fn account_secret_file(
1302 entry: &serde_json::Map<String, serde_json::Value>,
1303 key: &str,
1304 value: &serde_json::Value,
1305) -> Option<String> {
1306 if !ACCOUNT_SECRET_FIELDS.contains(&key) || !value.is_string() {
1307 return None;
1308 }
1309 entry
1310 .get(&format!("{key}_file"))
1311 .and_then(serde_json::Value::as_str)
1312 .filter(|file| !file.is_empty())
1313 .map(str::to_string)
1314}
1315
1316fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
1321 if let Some(parent) = path.parent() {
1322 if !parent.as_os_str().is_empty() {
1323 crate::daemon::paths::ensure_dir_0700(parent)?;
1324 }
1325 }
1326 let formatted =
1327 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
1328 write_file_0600(path, &formatted)
1329 .with_context(|| format!("Failed to write {}", path.display()))?;
1330 crate::daemon::paths::set_file_0600(path)?;
1331 Ok(())
1332}
1333
1334#[cfg(unix)]
1336fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1337 use std::io::Write;
1338 use std::os::unix::fs::OpenOptionsExt;
1339
1340 let mut file = fs::OpenOptions::new()
1341 .write(true)
1342 .create(true)
1343 .truncate(true)
1344 .mode(0o600)
1345 .open(path)?;
1346 file.write_all(contents.as_bytes())
1347}
1348
1349#[cfg(not(unix))]
1352fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1353 fs::write(path, contents)
1354}
1355
1356pub fn get_env_var(key: &str) -> Result<String> {
1359 get_env_var_with(&SystemEnv, Settings::load, key)
1360}
1361
1362pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
1369 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
1370}
1371
1372fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
1375where
1376 E: EnvSource,
1377 F: FnOnce() -> Result<Settings>,
1378{
1379 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
1380}
1381
1382fn get_env_var_sourced_with<E, F>(
1390 env: &E,
1391 load: F,
1392 from_cli_flag: bool,
1393 key: &str,
1394) -> Result<(String, EnvValueSource)>
1395where
1396 E: EnvSource,
1397 F: FnOnce() -> Result<Settings>,
1398{
1399 if let Some(value) = env.var(key) {
1403 let source = if from_cli_flag {
1404 EnvValueSource::CliFlag
1405 } else {
1406 EnvValueSource::ProcessEnv
1407 };
1408 return Ok((value, source));
1409 }
1410 match load() {
1411 Ok(settings) => settings
1412 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
1413 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
1414 Err(err) => {
1415 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
1417 }
1418 }
1419}
1420
1421pub fn get_env_vars(keys: &[&str]) -> Result<String> {
1423 for key in keys {
1424 if let Ok(value) = get_env_var(key) {
1425 return Ok(value);
1426 }
1427 }
1428
1429 Err(anyhow::anyhow!(
1430 "None of the environment variables found: {keys:?}"
1431 ))
1432}
1433
1434#[cfg(test)]
1435#[allow(clippy::unwrap_used, clippy::expect_used)]
1436mod tests {
1437 #[test]
1438 fn daemon_settings_are_optional_and_accept_directives() {
1439 for json in ["{}", r#"{"daemon":{}}"#] {
1440 let settings: super::Settings = serde_json::from_str(json).unwrap();
1441 assert!(settings.daemon.log_level.is_none());
1442 }
1443 let settings: super::Settings =
1444 serde_json::from_str(r#"{"daemon":{"log_level":"info,omni_dev::sessions=debug"}}"#)
1445 .unwrap();
1446 assert_eq!(
1447 settings.daemon.log_level.as_deref(),
1448 Some("info,omni_dev::sessions=debug")
1449 );
1450 }
1451
1452 use super::*;
1453 use crate::test_support::env::MapEnv;
1454 use std::env;
1455 use std::fs;
1456 use tempfile::TempDir;
1457
1458 fn settings_with_profile() -> Settings {
1461 let mut base = HashMap::new();
1462 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
1463 base.insert("SHARED".to_string(), "base-shared".to_string());
1464
1465 let mut work_env = HashMap::new();
1466 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
1467
1468 let mut profiles = HashMap::new();
1469 profiles.insert("work".to_string(), Profile { env: work_env });
1470
1471 Settings {
1472 env: base,
1473 profiles,
1474 ..Settings::default()
1475 }
1476 }
1477
1478 #[test]
1479 fn settings_load_from_path() {
1480 let temp_dir = {
1482 std::fs::create_dir_all("tmp").ok();
1483 TempDir::new_in("tmp").unwrap()
1484 };
1485 let settings_path = temp_dir.path().join("settings.json");
1486
1487 let settings_json = r#"{
1489 "env": {
1490 "TEST_VAR": "test_value",
1491 "CLAUDE_API_KEY": "test_api_key"
1492 }
1493 }"#;
1494 fs::write(&settings_path, settings_json).unwrap();
1495
1496 let settings = Settings::load_from_path(&settings_path).unwrap();
1498
1499 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
1501 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
1502 }
1503
1504 #[test]
1505 fn load_warn_dedup_warns_once_per_distinct_failure() {
1506 let dedup = LoadWarnDedup::new();
1507 assert!(dedup.observe(Some("broken A")));
1508 assert!(!dedup.observe(Some("broken A")), "a repeat is silent");
1509 assert!(dedup.observe(Some("broken B")), "a new failure warns");
1510 assert!(!dedup.observe(Some("broken B")));
1511 }
1512
1513 #[test]
1514 fn load_warn_dedup_rewarns_after_a_successful_load() {
1515 let dedup = LoadWarnDedup::new();
1518 assert!(dedup.observe(Some("broken")));
1519 assert!(!dedup.observe(None), "a success never warns");
1520 assert!(dedup.observe(Some("broken")));
1521 }
1522
1523 #[test]
1524 fn load_or_warn_default_warns_and_falls_back_when_settings_json_fails_to_parse() {
1525 let guard = crate::drive::test_support::EnvGuard::take();
1530 let dir = guard.clear_credentials();
1531 let settings_dir = dir.path().join(".omni-dev");
1532 fs::create_dir_all(&settings_dir).unwrap();
1533 fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1534
1535 let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1536 let settings = Settings::load_or_warn_default();
1537 assert!(settings.env.is_empty());
1538 assert!(settings.profiles.is_empty());
1539 assert!(settings.drive.accounts.is_empty());
1540 });
1541 assert!(logs.contains("settings.json"), "{logs}");
1542 }
1543
1544 #[test]
1545 fn load_daemon_reads_the_daemon_section_of_settings_json() {
1546 let guard = crate::drive::test_support::EnvGuard::take();
1547 let dir = guard.clear_credentials();
1548 let settings_dir = dir.path().join(".omni-dev");
1549 fs::create_dir_all(&settings_dir).unwrap();
1550 fs::write(
1551 settings_dir.join("settings.json"),
1552 r#"{"daemon":{"log_level":"omni_dev::sessions=debug"}}"#,
1553 )
1554 .unwrap();
1555
1556 assert_eq!(
1557 Settings::load_daemon().log_level.as_deref(),
1558 Some("omni_dev::sessions=debug")
1559 );
1560 }
1561
1562 #[test]
1563 fn load_daemon_warns_and_falls_back_when_settings_json_fails_to_parse() {
1564 let guard = crate::drive::test_support::EnvGuard::take();
1565 let dir = guard.clear_credentials();
1566 let settings_dir = dir.path().join(".omni-dev");
1567 fs::create_dir_all(&settings_dir).unwrap();
1568 fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1569
1570 let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1571 assert!(Settings::load_daemon().log_level.is_none());
1572 });
1573 assert!(logs.contains("settings.json"), "{logs}");
1574 }
1575
1576 #[test]
1577 fn a_settings_fallback_is_warned_about_once_per_distinct_failure() {
1578 let dedup = LoadWarnDedup::new();
1579 let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1580 warn_settings_fallback(&dedup, "broken A");
1581 warn_settings_fallback(&dedup, "broken A");
1582 warn_settings_fallback(&dedup, "broken B");
1583 });
1584 assert_eq!(logs.matches("broken A").count(), 1, "{logs}");
1585 assert_eq!(logs.matches("broken B").count(), 1, "{logs}");
1586 assert!(logs.contains("falling back to default settings"), "{logs}");
1587 }
1588
1589 #[test]
1590 fn warn_bootstrap_failure_reports_the_failure_it_is_handed() {
1591 let message = "Failed to parse settings file: /bootstrap-test/settings.json";
1596 let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1597 Settings::warn_bootstrap_failure(message);
1598 });
1599 assert!(logs.contains("/bootstrap-test/settings.json"), "{logs}");
1600 }
1601
1602 #[test]
1603 fn settings_get_env_var() {
1604 let temp_dir = {
1606 std::fs::create_dir_all("tmp").ok();
1607 TempDir::new_in("tmp").unwrap()
1608 };
1609 let settings_path = temp_dir.path().join("settings.json");
1610
1611 let settings_json = r#"{
1613 "env": {
1614 "TEST_VAR": "test_value",
1615 "CLAUDE_API_KEY": "test_api_key"
1616 }
1617 }"#;
1618 fs::write(&settings_path, settings_json).unwrap();
1619
1620 let settings = Settings::load_from_path(&settings_path).unwrap();
1622
1623 env::set_var("TEST_VAR_ENV", "env_value");
1625
1626 env::set_var("TEST_VAR", "env_override");
1628 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1629
1630 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1633
1634 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1636
1637 env::remove_var("TEST_VAR_ENV");
1639 }
1640
1641 #[test]
1644 fn resolve_no_profile_uses_base_env() {
1645 let settings = settings_with_profile();
1646 let raw = MapEnv::new();
1647 assert_eq!(
1648 settings
1649 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1650 .as_deref(),
1651 Some("base@x.com")
1652 );
1653 }
1654
1655 #[test]
1656 fn resolve_active_profile_uses_profile_env() {
1657 let settings = settings_with_profile();
1658 let raw = MapEnv::new();
1659 assert_eq!(
1660 settings
1661 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1662 .as_deref(),
1663 Some("me@work.com")
1664 );
1665 }
1666
1667 #[test]
1668 fn resolve_active_profile_does_not_consult_base() {
1669 let settings = settings_with_profile();
1672 let raw = MapEnv::new();
1673 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1674 }
1675
1676 #[test]
1677 fn resolve_process_env_wins_over_profile_and_base() {
1678 let settings = settings_with_profile();
1679 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1680 assert_eq!(
1681 settings
1682 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1683 .as_deref(),
1684 Some("cli@x.com")
1685 );
1686 assert_eq!(
1687 settings
1688 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1689 .as_deref(),
1690 Some("cli@x.com")
1691 );
1692 }
1693
1694 #[test]
1695 fn resolve_unknown_active_profile_yields_none() {
1696 let settings = settings_with_profile();
1699 let raw = MapEnv::new();
1700 assert_eq!(
1701 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1702 None
1703 );
1704 }
1705
1706 #[test]
1709 fn upsert_of_a_secret_replaces_its_file_companion_in_the_same_map() {
1710 let (_tmp, path) = temp_settings_path();
1711 fs::create_dir_all(path.parent().unwrap()).unwrap();
1712 fs::write(
1713 &path,
1714 r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k", "OTHER_FILE": "/keep"},
1715 "profiles": {"work": {"env": {"DATADOG_API_KEY_FILE": "/w"}}}}"#,
1716 )
1717 .unwrap();
1718
1719 Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1720
1721 let val = read_json(&path);
1722 assert_eq!(val["env"]["DATADOG_API_KEY"], "v");
1723 assert!(val["env"].get("DATADOG_API_KEY_FILE").is_none());
1724 assert_eq!(val["env"]["OTHER_FILE"], "/keep");
1726 assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY_FILE"], "/w");
1727 }
1728
1729 #[test]
1730 fn remove_of_a_secret_also_removes_its_file_companion() {
1731 let (_tmp, path) = temp_settings_path();
1732 fs::create_dir_all(path.parent().unwrap()).unwrap();
1733 fs::write(&path, r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k"}}"#).unwrap();
1734
1735 assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1736 assert!(read_json(&path)["env"]
1737 .get("DATADOG_API_KEY_FILE")
1738 .is_none());
1739 }
1740
1741 #[test]
1742 fn upsert_of_a_secret_refuses_when_its_command_is_set_in_the_same_map() {
1743 let (_tmp, path) = temp_settings_path();
1744 fs::create_dir_all(path.parent().unwrap()).unwrap();
1745 let before = r#"{"env": {"DATADOG_API_KEY_COMMAND": "op read op://v/dd"},
1746 "profiles": {"work": {"env": {"DATADOG_API_KEY_COMMAND": "/w"}}}}"#;
1747 fs::write(&path, before).unwrap();
1748
1749 let err = Settings::upsert_env_vars_in(
1750 &path,
1751 None,
1752 &[("DATADOG_SITE", "eu"), ("DATADOG_API_KEY", "s3cr3t-value")],
1753 )
1754 .unwrap_err()
1755 .to_string();
1756 assert!(err.contains("DATADOG_API_KEY_COMMAND"), "{err}");
1757 assert!(err.contains("nothing was saved"), "{err}");
1758 assert!(!err.contains("s3cr3t-value"), "the value leaked: {err}");
1759 assert!(!err.contains("op read"), "the command leaked: {err}");
1760 assert_eq!(fs::read_to_string(&path).unwrap(), before);
1762
1763 let err = Settings::upsert_env_vars_in(&path, Some("work"), &[("DATADOG_API_KEY", "v")])
1765 .unwrap_err()
1766 .to_string();
1767 assert!(err.contains("profiles.work.env"), "{err}");
1768 }
1769
1770 #[test]
1771 fn upsert_ignores_an_empty_command_and_other_maps_commands() {
1772 let (_tmp, path) = temp_settings_path();
1773 fs::create_dir_all(path.parent().unwrap()).unwrap();
1774 fs::write(
1775 &path,
1776 r#"{"env": {"DATADOG_API_KEY_COMMAND": ""},
1777 "profiles": {"work": {"env": {"DATADOG_APP_KEY_COMMAND": "/w"}}}}"#,
1778 )
1779 .unwrap();
1780 Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1781 assert_eq!(read_json(&path)["env"]["DATADOG_API_KEY"], "v");
1782 Settings::upsert_env_vars_in(&path, None, &[("DATADOG_APP_KEY", "v")]).unwrap();
1784 Settings::upsert_env_vars_in(&path, None, &[("DATADOG_SITE", "eu")]).unwrap();
1785 }
1786
1787 #[test]
1788 fn ensure_secrets_replaceable_is_the_same_check_without_writing() {
1789 let (_tmp, path) = temp_settings_path();
1790 let none = MapEnv::new();
1791 Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_API_KEY"], &none).unwrap();
1793 fs::create_dir_all(path.parent().unwrap()).unwrap();
1794 fs::write(&path, r#"{"env": {"DATADOG_API_KEY_COMMAND": "/c"}}"#).unwrap();
1795 assert!(
1796 Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_API_KEY"], &none).is_err()
1797 );
1798 Settings::ensure_secrets_replaceable(&path, None, &["DATADOG_APP_KEY"], &none).unwrap();
1800 Settings::ensure_secrets_replaceable(&path, Some("work"), &["DATADOG_API_KEY"], &none)
1801 .unwrap();
1802
1803 let (_tmp2, clean) = temp_settings_path();
1806 let exported = MapEnv::new().with("DATADOG_API_KEY_COMMAND", "op read op://v/dd");
1807 let err =
1808 Settings::ensure_secrets_replaceable(&clean, None, &["DATADOG_API_KEY"], &exported)
1809 .unwrap_err()
1810 .to_string();
1811 assert!(err.contains("set in the environment"), "{err}");
1812 assert!(!err.contains("op read"), "{err}");
1813 Settings::ensure_secrets_replaceable(&clean, None, &["DATADOG_APP_KEY"], &exported)
1814 .unwrap();
1815 }
1816
1817 #[test]
1818 fn remove_of_a_secret_also_removes_its_command_companion() {
1819 let (_tmp, path) = temp_settings_path();
1820 fs::create_dir_all(path.parent().unwrap()).unwrap();
1821 fs::write(
1822 &path,
1823 r#"{"env": {"DATADOG_API_KEY_COMMAND": "/c", "OTHER_COMMAND": "/keep"}}"#,
1824 )
1825 .unwrap();
1826
1827 assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1828 let val = read_json(&path);
1829 assert!(val["env"].get("DATADOG_API_KEY_COMMAND").is_none());
1830 assert_eq!(val["env"]["OTHER_COMMAND"], "/keep");
1831 }
1832
1833 fn settings_with_env(base: &[(&str, &str)], work: &[(&str, &str)]) -> Settings {
1834 let map = |pairs: &[(&str, &str)]| {
1835 pairs
1836 .iter()
1837 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1838 .collect::<HashMap<_, _>>()
1839 };
1840 let mut profiles = HashMap::new();
1841 profiles.insert("work".to_string(), Profile { env: map(work) });
1842 Settings {
1843 env: map(base),
1844 profiles,
1845 ..Settings::default()
1846 }
1847 }
1848
1849 #[test]
1850 fn resolve_triple_takes_the_process_env_triple_over_settings() {
1851 let settings = settings_with_env(&[("K", "from-settings")], &[]);
1854 let raw = MapEnv::new().with("K_FILE", "/run/k");
1855 assert_eq!(
1856 settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1857 (None, Some("/run/k".to_string()), None)
1858 );
1859 let settings = settings_with_env(&[("K", "v"), ("K_FILE", "/f")], &[]);
1861 let raw = MapEnv::new().with("K_COMMAND", "/bin/op");
1862 assert_eq!(
1863 settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1864 (None, None, Some("/bin/op".to_string()))
1865 );
1866 }
1867
1868 #[test]
1869 fn resolve_triple_falls_back_to_one_settings_layer_as_a_triple() {
1870 let settings = settings_with_env(
1871 &[("K", "v"), ("K_FILE", "/f"), ("K_COMMAND", "/c")],
1872 &[("K_FILE", "/w")],
1873 );
1874 let raw = MapEnv::new();
1875 assert_eq!(
1877 settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1878 (
1879 Some("v".to_string()),
1880 Some("/f".to_string()),
1881 Some("/c".to_string())
1882 )
1883 );
1884 assert_eq!(
1886 settings.resolve_triple_with(&raw, Some("work"), "K", "K_FILE", "K_COMMAND"),
1887 (None, Some("/w".to_string()), None)
1888 );
1889 assert_eq!(
1890 settings.resolve_triple_with(&raw, Some("nope"), "K", "K_FILE", "K_COMMAND"),
1891 (None, None, None)
1892 );
1893 }
1894
1895 #[test]
1896 fn resolve_triple_empty_process_env_value_neutralises_settings() {
1897 let settings = settings_with_env(&[("K", "from-settings")], &[]);
1899 let raw = MapEnv::new().with("K", "");
1900 assert_eq!(
1901 settings.resolve_triple_with(&raw, None, "K", "K_FILE", "K_COMMAND"),
1902 (Some(String::new()), None, None)
1903 );
1904 }
1905
1906 #[test]
1907 fn secret_resolver_over_settings_layers() {
1908 use crate::utils::secret_env::{secret_var, SecretEnvError};
1909 let dir = tempfile::tempdir().unwrap();
1910 let path = dir.path().join("key");
1911 std::fs::write(&path, "from-file\n").unwrap();
1912 #[cfg(unix)]
1913 {
1914 use std::os::unix::fs::PermissionsExt;
1915 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1916 }
1917 let path = path.to_str().unwrap();
1918
1919 struct Layered<'a>(&'a Settings, MapEnv);
1920 impl EnvSource for Layered<'_> {
1921 fn var(&self, key: &str) -> Option<String> {
1922 self.0.resolve_with(&self.1, None, key)
1923 }
1924 fn var_triple(&self, key: &str, file_key: &str, command_key: &str) -> SecretTriple {
1925 self.0
1926 .resolve_triple_with(&self.1, None, key, file_key, command_key)
1927 }
1928 }
1929
1930 let settings = settings_with_env(&[("DATADOG_API_KEY", "from-settings")], &[]);
1932 let env = Layered(&settings, MapEnv::new().with("DATADOG_API_KEY_FILE", path));
1933 let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1934 assert_eq!(got.expose_secret(), "from-file");
1935
1936 let settings = settings_with_env(&[("DATADOG_API_KEY_FILE", path)], &[]);
1938 let env = Layered(&settings, MapEnv::new());
1939 let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1940 assert_eq!(got.expose_secret(), "from-file");
1941
1942 let settings = settings_with_env(
1944 &[("DATADOG_API_KEY", "v"), ("DATADOG_API_KEY_FILE", path)],
1945 &[],
1946 );
1947 let env = Layered(&settings, MapEnv::new());
1948 assert!(matches!(
1949 secret_var(&env, "DATADOG_API_KEY").unwrap_err(),
1950 SecretEnvError::Conflict { .. }
1951 ));
1952 assert_eq!(env.var("DATADOG_API_KEY"), Some("v".to_string()));
1955 }
1956
1957 #[test]
1958 fn settings_env_ref_var_falls_back_to_the_settings_layer() {
1959 let settings = settings_with_env(
1964 &[("OMNI_DEV_TEST_SETTINGS_ENV_REF_K", "from-settings")],
1965 &[],
1966 );
1967 let env_ref = settings.env_source();
1968 assert_eq!(
1969 env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_K"),
1970 Some("from-settings".to_string())
1971 );
1972 assert_eq!(env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_MISSING"), None);
1973 }
1974
1975 #[test]
1978 fn resolve_with_source_process_env_is_process_env() {
1979 let settings = settings_with_profile();
1980 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1981 assert_eq!(
1982 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1983 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1984 );
1985 }
1986
1987 #[test]
1988 fn resolve_with_source_base_env_is_settings_env() {
1989 let settings = settings_with_profile();
1990 let raw = MapEnv::new();
1991 assert_eq!(
1992 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1993 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1994 );
1995 }
1996
1997 #[test]
1998 fn resolve_with_source_profile_env_names_profile() {
1999 let settings = settings_with_profile();
2000 let raw = MapEnv::new();
2001 assert_eq!(
2002 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
2003 Some((
2004 "me@work.com".to_string(),
2005 EnvValueSource::SettingsProfile("work".to_string())
2006 ))
2007 );
2008 }
2009
2010 #[test]
2011 fn resolve_with_source_missing_key_is_none() {
2012 let settings = settings_with_profile();
2013 let raw = MapEnv::new();
2014 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
2015 }
2016
2017 #[test]
2018 fn env_value_source_display_names_each_layer() {
2019 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
2020 assert_eq!(
2021 EnvValueSource::ProcessEnv.to_string(),
2022 "process environment variable (e.g. a shell export)"
2023 );
2024 assert_eq!(
2025 EnvValueSource::SettingsEnv.to_string(),
2026 "the env map in $HOME/.omni-dev/settings.json"
2027 );
2028 assert_eq!(
2029 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
2030 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
2031 );
2032 }
2033
2034 #[test]
2035 fn active_profile_from_reads_and_trims_empty() {
2036 assert_eq!(active_profile_from(&MapEnv::new()), None);
2037 assert_eq!(
2038 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
2039 None
2040 );
2041 assert_eq!(
2042 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
2043 Some("work")
2044 );
2045 }
2046
2047 #[test]
2048 fn profile_suffix_names_profile_or_is_empty() {
2049 assert_eq!(profile_suffix(None), "");
2050 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
2051 }
2052
2053 #[test]
2054 fn validate_profile_accepts_known() {
2055 assert!(settings_with_profile().validate_profile("work").is_ok());
2056 }
2057
2058 #[test]
2059 fn validate_profile_rejects_unknown_and_lists_sorted() {
2060 let mut settings = settings_with_profile();
2061 settings
2062 .profiles
2063 .insert("personal".to_string(), Profile::default());
2064 let err = settings.validate_profile("wrok").unwrap_err().to_string();
2065 assert_eq!(
2066 err,
2067 "unknown profile 'wrok'; known profiles: personal, work"
2068 );
2069 }
2070
2071 #[test]
2072 fn validate_profile_reports_none_when_empty() {
2073 let settings = Settings::default();
2074 let err = settings.validate_profile("work").unwrap_err().to_string();
2075 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
2076 }
2077
2078 #[test]
2079 fn settings_parse_profiles_from_json() {
2080 let json = r#"{
2081 "env": { "BASE": "b" },
2082 "profiles": {
2083 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
2084 }
2085 }"#;
2086 let settings: Settings = serde_json::from_str(json).unwrap();
2087 assert_eq!(settings.env.get("BASE").unwrap(), "b");
2088 assert_eq!(
2089 settings
2090 .profiles
2091 .get("work")
2092 .unwrap()
2093 .env
2094 .get("ATLASSIAN_EMAIL")
2095 .unwrap(),
2096 "me@work.com"
2097 );
2098 }
2099
2100 #[test]
2101 fn settings_without_profiles_key_defaults_empty() {
2102 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2103 assert!(settings.profiles.is_empty());
2104 }
2105
2106 #[test]
2107 fn settings_parse_mcp_section_from_json() {
2108 let json = r#"{
2109 "mcp": {
2110 "default_model": "claude-sonnet-4-6",
2111 "log_level": "info",
2112 "max_response_bytes": 204800
2113 }
2114 }"#;
2115 let settings: Settings = serde_json::from_str(json).unwrap();
2116 assert_eq!(
2117 settings.mcp.default_model.as_deref(),
2118 Some("claude-sonnet-4-6")
2119 );
2120 assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
2121 assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
2122 }
2123
2124 #[test]
2125 fn settings_without_mcp_key_defaults_all_none() {
2126 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2129 assert!(settings.mcp.default_model.is_none());
2130 assert!(settings.mcp.log_level.is_none());
2131 assert!(settings.mcp.max_response_bytes.is_none());
2132 }
2133
2134 #[test]
2135 fn settings_mcp_partial_section_leaves_others_none() {
2136 let settings: Settings =
2138 serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
2139 assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
2140 assert!(settings.mcp.default_model.is_none());
2141 assert!(settings.mcp.max_response_bytes.is_none());
2142 }
2143
2144 #[test]
2145 fn settings_parse_gmail_section_from_json() {
2146 let json = r#"{
2147 "gmail": {
2148 "default_account": "work",
2149 "accounts": {
2150 "work": {
2151 "client_id": "id",
2152 "client_secret": "secret",
2153 "refresh_token": "token",
2154 "scope": "https://www.googleapis.com/auth/gmail.modify",
2155 "email_address": "alice@work.com"
2156 }
2157 }
2158 }
2159 }"#;
2160 let settings: Settings = serde_json::from_str(json).unwrap();
2161 assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
2162 let account = settings.gmail.accounts.get("work").unwrap();
2163 assert_eq!(account.client_id.as_deref(), Some("id"));
2164 assert_eq!(account.client_secret.as_deref(), Some("secret"));
2165 assert_eq!(account.refresh_token.as_deref(), Some("token"));
2166 assert_eq!(
2167 account.scope.as_deref(),
2168 Some("https://www.googleapis.com/auth/gmail.modify")
2169 );
2170 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
2171 }
2172
2173 #[test]
2174 fn settings_without_gmail_key_defaults_empty() {
2175 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2176 assert!(settings.gmail.default_account.is_none());
2177 assert!(settings.gmail.accounts.is_empty());
2178 }
2179
2180 #[test]
2181 fn settings_parse_drive_section_from_json() {
2182 let json = r#"{
2183 "drive": {
2184 "default_account": "work",
2185 "accounts": {
2186 "work": {
2187 "client_id": "id",
2188 "client_secret": "secret",
2189 "refresh_token": "token",
2190 "scope": "https://www.googleapis.com/auth/drive.readonly",
2191 "email_address": "alice@work.com"
2192 }
2193 }
2194 }
2195 }"#;
2196 let settings: Settings = serde_json::from_str(json).unwrap();
2197 assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
2198 let account = settings.drive.accounts.get("work").unwrap();
2199 assert_eq!(account.client_id.as_deref(), Some("id"));
2200 assert_eq!(account.client_secret.as_deref(), Some("secret"));
2201 assert_eq!(account.refresh_token.as_deref(), Some("token"));
2202 assert_eq!(
2203 account.scope.as_deref(),
2204 Some("https://www.googleapis.com/auth/drive.readonly")
2205 );
2206 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
2207 }
2208
2209 #[test]
2210 fn settings_without_drive_key_defaults_empty() {
2211 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2212 assert!(settings.drive.default_account.is_none());
2213 assert!(settings.drive.accounts.is_empty());
2214 }
2215
2216 #[test]
2217 fn settings_parse_lease_section_from_json() {
2218 let json = r#"{
2219 "lease": {
2220 "default_expiry_minutes": 45,
2221 "backup_dir": "/tmp/drive-backups",
2222 "biometrics_only": true,
2223 "allow_headless": true
2224 }
2225 }"#;
2226 let settings: Settings = serde_json::from_str(json).unwrap();
2227 assert_eq!(settings.lease.default_expiry_minutes, Some(45));
2228 assert_eq!(
2229 settings.lease.backup_dir.as_deref(),
2230 Some(Path::new("/tmp/drive-backups"))
2231 );
2232 assert!(settings.lease.biometrics_only);
2233 assert!(settings.lease.allow_headless);
2234 }
2235
2236 #[test]
2237 fn settings_without_lease_key_defaults_all_unset() {
2238 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
2242 assert!(settings.lease.default_expiry_minutes.is_none());
2243 assert!(settings.lease.backup_dir.is_none());
2244 assert!(!settings.lease.biometrics_only);
2245 assert!(!settings.lease.allow_headless);
2246 }
2247
2248 #[test]
2249 fn settings_lease_partial_section_leaves_others_default() {
2250 let settings: Settings =
2251 serde_json::from_str(r#"{ "lease": { "biometrics_only": true } }"#).unwrap();
2252 assert!(settings.lease.biometrics_only);
2253 assert!(settings.lease.default_expiry_minutes.is_none());
2254 assert!(settings.lease.backup_dir.is_none());
2255 assert!(!settings.lease.allow_headless);
2256 }
2257
2258 #[test]
2261 fn get_env_var_with_returns_raw_hit_without_loading() {
2262 let env = MapEnv::new().with("K", "v");
2263 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
2264 assert_eq!(value, "v");
2265 }
2266
2267 #[test]
2268 fn get_env_var_with_falls_back_to_base_settings() {
2269 let settings = settings_with_profile();
2270 let env = MapEnv::new();
2271 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
2272 assert_eq!(value, "base@x.com");
2273 }
2274
2275 #[test]
2276 fn get_env_var_with_honours_active_profile() {
2277 let settings = settings_with_profile();
2278 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2279 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
2280 assert_eq!(value, "me@work.com");
2281 }
2282
2283 #[test]
2284 fn get_env_var_with_missing_key_is_not_found() {
2285 let env = MapEnv::new();
2286 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
2287 .unwrap_err()
2288 .to_string();
2289 assert!(err.contains("Environment variable not found: MISSING"));
2290 }
2291
2292 #[test]
2293 fn get_env_var_with_load_error_maps_to_not_found() {
2294 let env = MapEnv::new();
2295 let err =
2296 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
2297 assert_eq!(err.to_string(), "disk boom");
2300 let chain = format!("{err:#}");
2301 assert!(chain.contains("Environment variable not found: MISSING"));
2302 }
2303
2304 #[test]
2307 fn get_env_var_sourced_with_raw_hit_is_process_env() {
2308 let env = MapEnv::new().with("K", "v");
2309 let resolved =
2310 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
2311 .unwrap();
2312 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
2313 }
2314
2315 #[test]
2316 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
2317 let env = MapEnv::new().with("K", "true");
2318 let resolved =
2319 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
2320 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
2321 }
2322
2323 #[test]
2324 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
2325 let settings = settings_with_profile();
2326 let env = MapEnv::new();
2327 let resolved =
2328 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2329 assert_eq!(
2330 resolved,
2331 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
2332 );
2333
2334 let settings = settings_with_profile();
2335 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2336 let resolved =
2337 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2338 assert_eq!(
2339 resolved,
2340 (
2341 "me@work.com".to_string(),
2342 EnvValueSource::SettingsProfile("work".to_string())
2343 )
2344 );
2345 }
2346
2347 #[test]
2348 fn cli_flag_export_registry_roundtrip() {
2349 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
2352 assert!(!exported_by_cli_flag(KEY));
2353 note_cli_flag_export(KEY);
2354 assert!(exported_by_cli_flag(KEY));
2355 }
2356
2357 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
2362 let temp_dir = {
2363 std::fs::create_dir_all("tmp").ok();
2364 TempDir::new_in("tmp").unwrap()
2365 };
2366 let path = temp_dir.path().join(".omni-dev").join("settings.json");
2367 (temp_dir, path)
2368 }
2369
2370 fn read_json(path: &Path) -> serde_json::Value {
2371 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
2372 }
2373
2374 #[test]
2375 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
2376 let (_tmp, path) = temp_settings_path();
2377
2378 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
2379
2380 let val = read_json(&path);
2381 assert_eq!(val["env"]["A_KEY"], "a");
2382 assert_eq!(val["env"]["B_KEY"], "b");
2383
2384 #[cfg(unix)]
2386 {
2387 use std::os::unix::fs::PermissionsExt;
2388 let dir_mode = fs::metadata(path.parent().unwrap())
2389 .unwrap()
2390 .permissions()
2391 .mode();
2392 assert_eq!(dir_mode & 0o777, 0o700);
2393 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2394 assert_eq!(file_mode & 0o777, 0o600);
2395 }
2396 }
2397
2398 #[test]
2399 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
2400 let (_tmp, path) = temp_settings_path();
2401 fs::create_dir_all(path.parent().unwrap()).unwrap();
2402 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
2403
2404 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
2405
2406 let val = read_json(&path);
2407 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2408 assert_eq!(val["extra"], true);
2409 assert_eq!(val["env"]["A_KEY"], "new");
2410 }
2411
2412 #[test]
2413 fn upsert_env_vars_replaces_non_object_env() {
2414 let (_tmp, path) = temp_settings_path();
2415 fs::create_dir_all(path.parent().unwrap()).unwrap();
2416 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2417
2418 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2419
2420 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
2421 }
2422
2423 #[cfg(unix)]
2424 #[test]
2425 fn upsert_env_vars_retightens_loose_permissions() {
2426 use std::os::unix::fs::PermissionsExt;
2427
2428 let (_tmp, path) = temp_settings_path();
2429 fs::create_dir_all(path.parent().unwrap()).unwrap();
2430 fs::write(&path, r#"{"env": {}}"#).unwrap();
2431 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2432
2433 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2434
2435 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2436 assert_eq!(file_mode & 0o777, 0o600);
2437 }
2438
2439 #[test]
2440 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
2441 let (_tmp, path) = temp_settings_path();
2442 fs::create_dir_all(path.parent().unwrap()).unwrap();
2443 fs::write(
2444 &path,
2445 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
2446 )
2447 .unwrap();
2448
2449 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
2450 assert!(removed);
2451
2452 let val = read_json(&path);
2453 assert!(val["env"].get("A_KEY").is_none());
2454 assert!(val["env"].get("B_KEY").is_none());
2455 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2456 assert_eq!(val["extra"], true);
2457 }
2458
2459 #[test]
2460 fn remove_env_vars_false_when_file_missing() {
2461 let (_tmp, path) = temp_settings_path();
2462 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2463 assert!(!path.exists());
2464 }
2465
2466 #[test]
2467 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
2468 let (_tmp, path) = temp_settings_path();
2469 fs::create_dir_all(path.parent().unwrap()).unwrap();
2470
2471 fs::write(&path, r#"{"extra": true}"#).unwrap();
2473 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2474
2475 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2477 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2478 }
2479
2480 #[test]
2481 fn upsert_env_vars_bare_filename_skips_dir_creation() {
2482 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
2485 let path = Path::new(&name);
2486
2487 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
2488
2489 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
2490 fs::remove_file(path).unwrap();
2491 }
2492
2493 #[test]
2494 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
2495 let (_tmp, path) = temp_settings_path();
2496 fs::create_dir_all(path.parent().unwrap()).unwrap();
2497 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
2498 fs::write(&path, original).unwrap();
2499
2500 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
2501 assert!(!removed);
2502 assert_eq!(fs::read_to_string(&path).unwrap(), original);
2504 }
2505
2506 #[test]
2509 fn upsert_env_vars_in_profile_creates_profile_env() {
2510 let (_tmp, path) = temp_settings_path();
2511
2512 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2513
2514 let val = read_json(&path);
2515 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2516 assert!(val.get("env").is_none());
2518
2519 #[cfg(unix)]
2522 {
2523 use std::os::unix::fs::PermissionsExt;
2524 let dir_mode = fs::metadata(path.parent().unwrap())
2525 .unwrap()
2526 .permissions()
2527 .mode();
2528 assert_eq!(dir_mode & 0o777, 0o700);
2529 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2530 assert_eq!(file_mode & 0o777, 0o600);
2531 }
2532 }
2533
2534 #[test]
2535 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
2536 let (_tmp, path) = temp_settings_path();
2537 fs::create_dir_all(path.parent().unwrap()).unwrap();
2538 fs::write(
2539 &path,
2540 r#"{
2541 "env": {"SHARED": "base"},
2542 "profiles": {
2543 "work": {"env": {"OLD": "keep"}},
2544 "home": {"env": {"SHARED": "home"}}
2545 },
2546 "extra": true
2547 }"#,
2548 )
2549 .unwrap();
2550
2551 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2552
2553 let val = read_json(&path);
2554 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2555 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
2556 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
2557 assert_eq!(val["env"]["SHARED"], "base");
2558 assert_eq!(val["extra"], true);
2559 }
2560
2561 #[test]
2562 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
2563 let (_tmp, path) = temp_settings_path();
2564 fs::create_dir_all(path.parent().unwrap()).unwrap();
2565
2566 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
2568 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2569 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2570
2571 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
2573 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2574 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2575 }
2576
2577 #[test]
2578 fn remove_env_vars_in_profile_removes_only_profile_keys() {
2579 let (_tmp, path) = temp_settings_path();
2580 fs::create_dir_all(path.parent().unwrap()).unwrap();
2581 fs::write(
2582 &path,
2583 r#"{
2584 "env": {"A_KEY": "base"},
2585 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
2586 }"#,
2587 )
2588 .unwrap();
2589
2590 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2591 assert!(removed);
2592
2593 let val = read_json(&path);
2594 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
2595 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
2596 assert_eq!(val["env"]["A_KEY"], "base");
2598 }
2599
2600 #[test]
2601 fn remove_env_vars_in_profile_false_when_profile_missing() {
2602 let (_tmp, path) = temp_settings_path();
2603 fs::create_dir_all(path.parent().unwrap()).unwrap();
2604 let original = r#"{"env": {"A_KEY": "base"}}"#;
2605 fs::write(&path, original).unwrap();
2606
2607 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2608 assert!(!removed);
2609 assert_eq!(fs::read_to_string(&path).unwrap(), original);
2611 }
2612
2613 #[test]
2614 fn remove_env_vars_in_none_targets_base_env() {
2615 let (_tmp, path) = temp_settings_path();
2616 fs::create_dir_all(path.parent().unwrap()).unwrap();
2617 fs::write(
2618 &path,
2619 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
2620 )
2621 .unwrap();
2622
2623 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
2624 assert!(removed);
2625
2626 let val = read_json(&path);
2627 assert!(val["env"].get("A_KEY").is_none());
2628 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
2629 }
2630
2631 #[test]
2634 fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
2635 let mut root = serde_json::json!({});
2636 {
2637 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2638 map.insert("client_id".to_string(), serde_json::json!("id"));
2639 }
2640 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2641 }
2642
2643 #[test]
2644 fn ensure_object_at_replaces_non_object_nodes_along_path() {
2645 let mut root = serde_json::json!({"gmail": "bogus"});
2646 {
2647 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2648 map.insert("client_id".to_string(), serde_json::json!("id"));
2649 }
2650 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2651 }
2652
2653 #[test]
2654 fn object_at_mut_none_when_any_segment_absent() {
2655 let mut root = serde_json::json!({"gmail": {"accounts": {}}});
2656 assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
2657 assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
2658 }
2659
2660 #[test]
2663 fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
2664 let (_tmp, path) = temp_settings_path();
2665 fs::create_dir_all(path.parent().unwrap()).unwrap();
2666 fs::write(
2667 &path,
2668 r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2669 )
2670 .unwrap();
2671
2672 Settings::upsert_gmail_account(
2673 &path,
2674 "work",
2675 &[
2676 ("client_id", serde_json::Value::String("id".to_string())),
2677 (
2678 "refresh_token",
2679 serde_json::Value::String("token".to_string()),
2680 ),
2681 ],
2682 )
2683 .unwrap();
2684
2685 let val = read_json(&path);
2686 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2687 assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
2688 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2689 assert_eq!(val["env"]["SHARED"], "base");
2690 assert_eq!(val["extra"], true);
2691
2692 #[cfg(unix)]
2693 {
2694 use std::os::unix::fs::PermissionsExt;
2695 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2696 assert_eq!(file_mode & 0o777, 0o600);
2697 }
2698 }
2699
2700 #[test]
2707 fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2708 let (_tmp, path) = temp_settings_path();
2709
2710 Settings::upsert_gmail_account(
2711 &path,
2712 "work",
2713 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2714 )
2715 .unwrap();
2716
2717 let val = read_json(&path);
2718 assert_eq!(
2719 val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
2720 true
2721 );
2722
2723 let settings = Settings::load_from_path(&path).unwrap();
2724 assert!(
2725 settings.gmail.accounts["work"].chrome_profile_from_email,
2726 "the bool field must deserialize back to `true`, not the string \"true\""
2727 );
2728 }
2729
2730 #[test]
2731 fn remove_gmail_account_true_when_present_false_when_absent() {
2732 let (_tmp, path) = temp_settings_path();
2733 fs::create_dir_all(path.parent().unwrap()).unwrap();
2734 fs::write(
2735 &path,
2736 r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2737 )
2738 .unwrap();
2739
2740 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2741 let val = read_json(&path);
2742 assert!(val["gmail"]["accounts"].get("work").is_none());
2743 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2744
2745 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2746 }
2747
2748 #[test]
2749 fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
2750 let (_tmp, path) = temp_settings_path();
2751 fs::create_dir_all(path.parent().unwrap()).unwrap();
2752 fs::write(
2753 &path,
2754 r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2755 )
2756 .unwrap();
2757
2758 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2759 let val = read_json(&path);
2760 assert!(val["gmail"].get("default_account").is_none());
2761 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2762 }
2763
2764 #[test]
2765 fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2766 let (_tmp, path) = temp_settings_path();
2767 fs::create_dir_all(path.parent().unwrap()).unwrap();
2768 fs::write(
2769 &path,
2770 r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2771 )
2772 .unwrap();
2773
2774 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2775 let val = read_json(&path);
2776 assert_eq!(val["gmail"]["default_account"], "personal");
2777 }
2778
2779 #[test]
2780 fn remove_gmail_account_false_when_file_missing() {
2781 let (_tmp, path) = temp_settings_path();
2782 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2783 assert!(!path.exists());
2784 }
2785
2786 #[test]
2787 fn set_gmail_default_account_sets_and_clears() {
2788 let (_tmp, path) = temp_settings_path();
2789
2790 Settings::set_gmail_default_account(&path, Some("work")).unwrap();
2791 assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
2792
2793 Settings::set_gmail_default_account(&path, None).unwrap();
2794 assert!(read_json(&path)["gmail"].get("default_account").is_none());
2795 }
2796
2797 #[test]
2800 fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
2801 let (_tmp, path) = temp_settings_path();
2802 fs::create_dir_all(path.parent().unwrap()).unwrap();
2803 fs::write(
2804 &path,
2805 r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2806 )
2807 .unwrap();
2808
2809 Settings::upsert_drive_account(
2810 &path,
2811 "work",
2812 &[
2813 ("client_id", serde_json::Value::String("id".to_string())),
2814 (
2815 "refresh_token",
2816 serde_json::Value::String("token".to_string()),
2817 ),
2818 ],
2819 )
2820 .unwrap();
2821
2822 let val = read_json(&path);
2823 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2824 assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
2825 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2826 assert_eq!(val["env"]["SHARED"], "base");
2827 assert_eq!(val["extra"], true);
2828
2829 #[cfg(unix)]
2830 {
2831 use std::os::unix::fs::PermissionsExt;
2832 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2833 assert_eq!(file_mode & 0o777, 0o600);
2834 }
2835 }
2836
2837 fn settings_with_secret_files(
2842 path: &Path,
2843 client_secret_file: Option<&Path>,
2844 refresh_token_file: &Path,
2845 ) {
2846 let mut work = serde_json::json!({
2847 "client_id": "id",
2848 "refresh_token_file": refresh_token_file.to_str().unwrap(),
2849 });
2850 if let Some(file) = client_secret_file {
2851 work["client_secret_file"] = serde_json::json!(file.to_str().unwrap());
2852 }
2853 fs::create_dir_all(path.parent().unwrap()).unwrap();
2854 fs::write(
2855 path,
2856 serde_json::json!({ "drive": { "accounts": { "work": work } } }).to_string(),
2857 )
2858 .unwrap();
2859 }
2860
2861 #[test]
2862 fn account_secret_file_fields_deserialize() {
2863 let (_tmp, path) = temp_settings_path();
2864 settings_with_secret_files(&path, Some(Path::new("/s/cs")), Path::new("/s/rt"));
2865 let settings = Settings::load_from_path(&path).unwrap();
2866 let work = &settings.drive.accounts["work"];
2867 assert_eq!(work.client_secret_file.as_deref(), Some("/s/cs"));
2868 assert_eq!(work.refresh_token_file.as_deref(), Some("/s/rt"));
2869 assert!(work.client_secret.is_none());
2870 }
2871
2872 #[test]
2873 fn upsert_account_writes_a_secret_into_its_file_not_settings() {
2874 let (tmp, path) = temp_settings_path();
2875 let token_file = tmp.path().join("refresh-token");
2876 settings_with_secret_files(&path, None, &token_file);
2877
2878 Settings::upsert_drive_account(
2879 &path,
2880 "work",
2881 &[
2882 ("client_secret", serde_json::json!("plain-secret")),
2883 ("refresh_token", serde_json::json!("new-token")),
2884 ("scope", serde_json::json!("s")),
2885 ],
2886 )
2887 .unwrap();
2888
2889 let raw = fs::read_to_string(&path).unwrap();
2890 assert!(!raw.contains("new-token"), "{raw}");
2891 let val = read_json(&path);
2892 let work = &val["drive"]["accounts"]["work"];
2893 assert!(work.get("refresh_token").is_none());
2894 assert_eq!(work["refresh_token_file"], token_file.to_str().unwrap());
2895 assert_eq!(work["client_secret"], "plain-secret");
2897 assert_eq!(work["scope"], "s");
2898 assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2899 }
2900
2901 #[test]
2902 fn upsert_account_drops_a_plain_field_that_would_conflict_with_its_file() {
2903 let (tmp, path) = temp_settings_path();
2904 let secret_file = tmp.path().join("client-secret");
2905 fs::create_dir_all(path.parent().unwrap()).unwrap();
2906 fs::write(
2907 &path,
2908 serde_json::json!({ "gmail": { "accounts": { "work": {
2909 "client_secret": "stale",
2910 "client_secret_file": secret_file.to_str().unwrap(),
2911 }}}})
2912 .to_string(),
2913 )
2914 .unwrap();
2915
2916 Settings::upsert_gmail_account(
2917 &path,
2918 "work",
2919 &[("client_secret", serde_json::json!("fresh"))],
2920 )
2921 .unwrap();
2922
2923 let val = read_json(&path);
2924 assert!(val["gmail"]["accounts"]["work"]
2925 .get("client_secret")
2926 .is_none());
2927 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "fresh\n");
2928 }
2929
2930 #[test]
2931 fn upsert_account_leaves_settings_untouched_when_the_secret_write_fails() {
2932 let (tmp, path) = temp_settings_path();
2933 let token_file = tmp.path().join("missing-dir").join("refresh-token");
2934 settings_with_secret_files(&path, None, &token_file);
2935 let before = fs::read_to_string(&path).unwrap();
2936
2937 let err = Settings::upsert_drive_account(
2938 &path,
2939 "work",
2940 &[
2941 ("scope", serde_json::json!("s")),
2942 ("refresh_token", serde_json::json!("new-token")),
2943 ],
2944 )
2945 .unwrap_err();
2946
2947 assert!(
2948 err.to_string()
2949 .contains("drive.accounts.work.refresh_token_file"),
2950 "{err}"
2951 );
2952 assert!(!err.to_string().contains("new-token"), "{err}");
2953 assert_eq!(fs::read_to_string(&path).unwrap(), before);
2954 }
2955
2956 fn owner_only(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
2958 let file = dir.join(name);
2959 fs::write(&file, contents).unwrap();
2960 #[cfg(unix)]
2961 {
2962 use std::os::unix::fs::PermissionsExt;
2963 fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
2964 }
2965 file
2966 }
2967
2968 #[test]
2969 fn upsert_account_checks_every_secret_file_before_writing_any() {
2970 let (tmp, path) = temp_settings_path();
2971 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2972 let token_file = tmp.path().join("missing-dir").join("refresh-token");
2973 settings_with_secret_files(&path, Some(&secret_file), &token_file);
2974
2975 Settings::upsert_drive_account(
2976 &path,
2977 "work",
2978 &[
2979 ("client_secret", serde_json::json!("new-secret")),
2980 ("refresh_token", serde_json::json!("new-token")),
2981 ],
2982 )
2983 .unwrap_err();
2984
2985 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2986 }
2987
2988 #[test]
2989 fn upsert_account_refuses_to_replace_a_client_secret_file_of_another_client() {
2990 let (tmp, path) = temp_settings_path();
2991 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2992 let token_file = tmp.path().join("refresh-token");
2993 settings_with_secret_files(&path, Some(&secret_file), &token_file);
2994 let before = fs::read_to_string(&path).unwrap();
2995
2996 let err = Settings::upsert_drive_account(
2997 &path,
2998 "work",
2999 &[
3000 ("client_id", serde_json::json!("other-id")),
3001 ("client_secret", serde_json::json!("other-secret")),
3002 ("refresh_token", serde_json::json!("new-token")),
3003 ],
3004 )
3005 .unwrap_err();
3006
3007 let message = err.to_string();
3008 assert!(
3009 message.contains("drive.accounts.work.client_secret_file"),
3010 "{message}"
3011 );
3012 assert!(message.contains("other-id"), "{message}");
3013 assert!(!message.contains("other-secret"), "{message}");
3014 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
3015 assert!(!token_file.exists());
3016 assert_eq!(fs::read_to_string(&path).unwrap(), before);
3017 }
3018
3019 #[test]
3020 fn upsert_account_replaces_a_client_secret_file_for_the_same_client() {
3021 let (tmp, path) = temp_settings_path();
3022 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
3023 let token_file = tmp.path().join("refresh-token");
3024 settings_with_secret_files(&path, Some(&secret_file), &token_file);
3025
3026 Settings::upsert_drive_account(
3027 &path,
3028 "work",
3029 &[
3030 ("client_id", serde_json::json!("id")),
3031 ("client_secret", serde_json::json!("rotated-secret")),
3032 ("refresh_token", serde_json::json!("new-token")),
3033 ],
3034 )
3035 .unwrap();
3036
3037 assert_eq!(
3038 fs::read_to_string(&secret_file).unwrap(),
3039 "rotated-secret\n"
3040 );
3041 assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
3042 }
3043
3044 #[test]
3045 fn upsert_gmail_account_writes_a_refresh_token_into_its_file() {
3046 let (tmp, path) = temp_settings_path();
3047 let token_file = tmp.path().join("gmail-refresh-token");
3048 fs::create_dir_all(path.parent().unwrap()).unwrap();
3049 fs::write(
3050 &path,
3051 serde_json::json!({ "gmail": { "accounts": { "work": {
3052 "refresh_token_file": token_file.to_str().unwrap(),
3053 }}}})
3054 .to_string(),
3055 )
3056 .unwrap();
3057
3058 Settings::upsert_gmail_account(
3059 &path,
3060 "work",
3061 &[("refresh_token", serde_json::json!("gmail-token"))],
3062 )
3063 .unwrap();
3064
3065 assert!(!fs::read_to_string(&path).unwrap().contains("gmail-token"));
3066 assert_eq!(fs::read_to_string(&token_file).unwrap(), "gmail-token\n");
3067 let settings = Settings::load_from_path(&path).unwrap();
3068 assert!(settings.gmail.accounts["work"].refresh_token.is_none());
3069 }
3070
3071 #[test]
3076 fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
3077 let (_tmp, path) = temp_settings_path();
3078
3079 Settings::upsert_drive_account(
3080 &path,
3081 "work",
3082 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
3083 )
3084 .unwrap();
3085
3086 let val = read_json(&path);
3087 assert_eq!(
3088 val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
3089 true
3090 );
3091
3092 let settings = Settings::load_from_path(&path).unwrap();
3093 assert!(
3094 settings.drive.accounts["work"].chrome_profile_from_email,
3095 "the bool field must deserialize back to `true`, not the string \"true\""
3096 );
3097 }
3098
3099 #[test]
3102 fn write_permissions_field_round_trips_through_settings_load() {
3103 let (_tmp, path) = temp_settings_path();
3104 fs::create_dir_all(path.parent().unwrap()).unwrap();
3105 fs::write(
3106 &path,
3107 r#"{
3108 "drive": {
3109 "accounts": {
3110 "work": {
3111 "write_permissions": {
3112 "rules": [
3113 {
3114 "folder_id": "folder-1",
3115 "recursive": true,
3116 "allow": ["create", "upload"],
3117 "deny": ["edit"]
3118 }
3119 ]
3120 }
3121 }
3122 }
3123 }
3124 }"#,
3125 )
3126 .unwrap();
3127
3128 let settings = Settings::load_from_path(&path).unwrap();
3129 let rules = &settings.drive.accounts["work"].write_permissions.rules;
3130 assert_eq!(rules.len(), 1);
3131 assert_eq!(rules[0].folder_id.as_deref(), Some("folder-1"));
3132 assert!(rules[0].recursive);
3133 assert!(rules[0]
3134 .allow
3135 .contains(&crate::drive::write_gate::DriveOperation::Create));
3136 assert!(rules[0]
3137 .allow
3138 .contains(&crate::drive::write_gate::DriveOperation::Upload));
3139 assert!(rules[0]
3140 .deny
3141 .contains(&crate::drive::write_gate::DriveOperation::Edit));
3142 }
3143
3144 #[test]
3145 fn write_permissions_absent_defaults_to_empty_rules() {
3146 let (_tmp, path) = temp_settings_path();
3147 fs::create_dir_all(path.parent().unwrap()).unwrap();
3148 fs::write(
3149 &path,
3150 r#"{"drive": {"accounts": {"work": {"client_id": "id"}}}}"#,
3151 )
3152 .unwrap();
3153
3154 let settings = Settings::load_from_path(&path).unwrap();
3155 assert!(settings.drive.accounts["work"]
3156 .write_permissions
3157 .rules
3158 .is_empty());
3159 }
3160
3161 #[test]
3169 fn write_permissions_unknown_future_field_is_preserved_through_read_modify_write() {
3170 let (_tmp, path) = temp_settings_path();
3171 fs::create_dir_all(path.parent().unwrap()).unwrap();
3172 fs::write(
3173 &path,
3174 r#"{
3175 "drive": {
3176 "accounts": {
3177 "work": {
3178 "write_permissions": {
3179 "rules": [],
3180 "future_field": "not yet modeled"
3181 }
3182 }
3183 }
3184 }
3185 }"#,
3186 )
3187 .unwrap();
3188
3189 Settings::upsert_drive_account(
3190 &path,
3191 "work",
3192 &[("client_id", serde_json::Value::String("id".to_string()))],
3193 )
3194 .unwrap();
3195
3196 let val = read_json(&path);
3197 assert_eq!(
3198 val["drive"]["accounts"]["work"]["write_permissions"]["future_field"],
3199 "not yet modeled"
3200 );
3201 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
3202 }
3203
3204 #[test]
3205 fn remove_drive_account_true_when_present_false_when_absent() {
3206 let (_tmp, path) = temp_settings_path();
3207 fs::create_dir_all(path.parent().unwrap()).unwrap();
3208 fs::write(
3209 &path,
3210 r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3211 )
3212 .unwrap();
3213
3214 assert!(Settings::remove_drive_account(&path, "work").unwrap());
3215 let val = read_json(&path);
3216 assert!(val["drive"]["accounts"].get("work").is_none());
3217 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
3218
3219 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
3220 }
3221
3222 #[test]
3223 fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
3224 let (_tmp, path) = temp_settings_path();
3225 fs::create_dir_all(path.parent().unwrap()).unwrap();
3226 fs::write(
3227 &path,
3228 r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3229 )
3230 .unwrap();
3231
3232 assert!(Settings::remove_drive_account(&path, "work").unwrap());
3233 let val = read_json(&path);
3234 assert!(val["drive"].get("default_account").is_none());
3235 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
3236 }
3237
3238 #[test]
3239 fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
3240 let (_tmp, path) = temp_settings_path();
3241 fs::create_dir_all(path.parent().unwrap()).unwrap();
3242 fs::write(
3243 &path,
3244 r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
3245 )
3246 .unwrap();
3247
3248 assert!(Settings::remove_drive_account(&path, "work").unwrap());
3249 let val = read_json(&path);
3250 assert_eq!(val["drive"]["default_account"], "personal");
3251 }
3252
3253 #[test]
3254 fn remove_drive_account_false_when_file_missing() {
3255 let (_tmp, path) = temp_settings_path();
3256 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
3257 assert!(!path.exists());
3258 }
3259
3260 #[test]
3261 fn set_drive_default_account_sets_and_clears() {
3262 let (_tmp, path) = temp_settings_path();
3263
3264 Settings::set_drive_default_account(&path, Some("work")).unwrap();
3265 assert_eq!(read_json(&path)["drive"]["default_account"], "work");
3266
3267 Settings::set_drive_default_account(&path, None).unwrap();
3268 assert!(read_json(&path)["drive"].get("default_account").is_none());
3269 }
3270
3271 fn load_with_rules(rules_json: &str) -> Result<Settings> {
3275 let (_tmp, path) = temp_settings_path();
3276 fs::create_dir_all(path.parent().unwrap()).unwrap();
3277 fs::write(
3278 &path,
3279 format!(
3280 r#"{{"drive":{{"accounts":{{"work":{{"write_permissions":{{"rules":{rules_json}}}}}}}}}}}"#
3281 ),
3282 )
3283 .unwrap();
3284 Settings::load_from_path(&path)
3285 }
3286
3287 #[test]
3288 fn a_file_id_rule_round_trips_through_settings_load() {
3289 let settings =
3290 load_with_rules(r#"[{"file_id":"sheet-1","allow":["sheets-write"]}]"#).unwrap();
3291 let rules = &settings.drive.accounts["work"].write_permissions.rules;
3292 assert_eq!(rules.len(), 1);
3293 assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3294 assert_eq!(rules[0].folder_id, None);
3295 assert!(!rules[0].recursive);
3296 assert!(rules[0]
3297 .allow
3298 .contains(&crate::drive::write_gate::DriveOperation::SheetsWrite));
3299 }
3300
3301 #[test]
3302 fn folder_and_file_rules_coexist_in_one_list() {
3303 let settings = load_with_rules(
3304 r#"[{"folder_id":"f1","recursive":true,"allow":["create"]},
3305 {"file_id":"x1","deny":["edit"]}]"#,
3306 )
3307 .unwrap();
3308 let rules = &settings.drive.accounts["work"].write_permissions.rules;
3309 assert_eq!(rules.len(), 2);
3310 assert_eq!(rules[0].folder_id.as_deref(), Some("f1"));
3311 assert_eq!(rules[1].file_id.as_deref(), Some("x1"));
3312 }
3313
3314 #[test]
3315 fn a_rule_naming_no_target_fails_the_whole_load() {
3316 assert!(load_with_rules(r#"[{"allow":["create"]}]"#).is_err());
3319 }
3320
3321 #[test]
3322 fn a_rule_naming_both_targets_fails_the_whole_load() {
3323 assert!(load_with_rules(r#"[{"folder_id":"f1","file_id":"x1"}]"#).is_err());
3324 }
3325
3326 #[test]
3327 fn recursive_true_on_a_file_rule_fails_the_whole_load() {
3328 assert!(load_with_rules(r#"[{"file_id":"x1","recursive":true}]"#).is_err());
3329 }
3330
3331 #[test]
3332 fn an_older_binary_would_fail_closed_on_a_file_rule() {
3333 #[derive(Debug, serde::Deserialize)]
3341 #[allow(dead_code)]
3342 struct OldFolderPermissionRule {
3343 folder_id: String,
3344 }
3345 let err = serde_json::from_str::<OldFolderPermissionRule>(r#"{"file_id":"x1"}"#)
3346 .unwrap_err()
3347 .to_string();
3348 assert!(err.contains("folder_id"), "{err}");
3349 }
3350
3351 #[test]
3352 fn upsert_drive_account_preserves_file_id_rules_it_cannot_understand() {
3353 let (_tmp, path) = temp_settings_path();
3357 fs::create_dir_all(path.parent().unwrap()).unwrap();
3358 fs::write(
3359 &path,
3360 r#"{"drive":{"accounts":{"work":{"write_permissions":{"rules":[
3361 {"file_id":"sheet-1","allow":["sheets-write"]}]}}}}}"#,
3362 )
3363 .unwrap();
3364
3365 Settings::upsert_drive_account(
3366 &path,
3367 "work",
3368 &[("email", serde_json::Value::String("a@b.c".to_string()))],
3369 )
3370 .unwrap();
3371
3372 let reloaded = Settings::load_from_path(&path).unwrap();
3373 let rules = &reloaded.drive.accounts["work"].write_permissions.rules;
3374 assert_eq!(rules.len(), 1);
3375 assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3376 }
3377}