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};
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 Settings {
378 #[serde(default)]
381 pub env: HashMap<String, String>,
382
383 #[serde(default)]
386 pub profiles: HashMap<String, Profile>,
387
388 #[serde(default)]
391 pub mcp: McpSettings,
392
393 #[serde(default)]
396 pub gmail: GmailSettings,
397
398 #[serde(default)]
401 pub drive: DriveSettings,
402
403 #[serde(default)]
406 pub lease: LeaseSettings,
407}
408
409pub fn active_profile_from<E: EnvSource>(raw: &E) -> Option<String> {
415 raw.var(PROFILE_ENV_VAR).filter(|s| !s.is_empty())
416}
417
418#[must_use]
422pub fn profile_suffix(profile: Option<&str>) -> String {
423 profile.map_or_else(String::new, |name| format!(" (profile '{name}')"))
424}
425
426#[derive(Debug, Default)]
437pub struct SettingsEnv {
438 settings: Settings,
439 active_profile: Option<String>,
440}
441
442impl SettingsEnv {
443 pub fn load() -> Self {
448 Self::load_with_profile(active_profile_from(&SystemEnv).as_deref())
449 }
450
451 pub fn load_with_profile(profile: Option<&str>) -> Self {
455 Self {
456 settings: Settings::load_or_warn_default(),
457 active_profile: profile.map(str::to_string),
458 }
459 }
460
461 pub fn from_settings(settings: Settings, profile: Option<&str>) -> Self {
468 Self {
469 settings,
470 active_profile: profile.map(str::to_string),
471 }
472 }
473}
474
475impl EnvSource for SettingsEnv {
476 fn var(&self, key: &str) -> Option<String> {
477 self.settings
478 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
479 }
480
481 fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
482 self.settings
483 .resolve_pair_with(&SystemEnv, self.active_profile.as_deref(), key, file_key)
484 }
485}
486
487#[derive(Debug)]
491pub struct SettingsEnvRef<'a> {
492 settings: &'a Settings,
493 active_profile: Option<String>,
494}
495
496impl EnvSource for SettingsEnvRef<'_> {
497 fn var(&self, key: &str) -> Option<String> {
498 self.settings
499 .resolve_with(&SystemEnv, self.active_profile.as_deref(), key)
500 }
501
502 fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
503 self.settings
504 .resolve_pair_with(&SystemEnv, self.active_profile.as_deref(), key, file_key)
505 }
506}
507
508struct LoadWarnDedup(Mutex<Option<String>>);
514
515impl LoadWarnDedup {
516 const fn new() -> Self {
517 Self(Mutex::new(None))
518 }
519
520 fn observe(&self, failure: Option<&str>) -> bool {
523 let mut last = self
524 .0
525 .lock()
526 .unwrap_or_else(std::sync::PoisonError::into_inner);
527 match failure {
528 None => {
529 *last = None;
530 false
531 }
532 Some(message) if last.as_deref() == Some(message) => false,
533 Some(message) => {
534 *last = Some(message.to_string());
535 true
536 }
537 }
538 }
539}
540
541static LOAD_WARN_DEDUP: LoadWarnDedup = LoadWarnDedup::new();
542
543fn is_secret_env_var(key: &str) -> bool {
545 secret_env::SECRET_ENV_VARS.contains(&key)
546}
547
548impl Settings {
549 pub fn load() -> Result<Self> {
551 let settings_path = Self::get_settings_path()?;
552 Self::load_from_path(&settings_path)
553 }
554
555 pub fn load_or_warn_default() -> Self {
567 match Self::load() {
568 Ok(settings) => {
569 LOAD_WARN_DEDUP.observe(None);
570 settings
571 }
572 Err(e) => {
573 let message = format!("{e:#}");
574 if LOAD_WARN_DEDUP.observe(Some(&message)) {
575 tracing::warn!(
576 "{message}; falling back to default settings for this invocation — \
577 any settings.json configuration is being ignored"
578 );
579 }
580 Self::default()
581 }
582 }
583 }
584
585 pub fn load_mcp() -> McpSettings {
591 Self::load_or_warn_default().mcp
592 }
593
594 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
596 let path = path.as_ref();
597
598 if !path.exists() {
600 return Ok(Self::default());
601 }
602
603 let content = fs::read_to_string(path)
605 .with_context(|| format!("Failed to read settings file: {}", path.display()))?;
606
607 serde_json::from_str::<Self>(&content)
608 .with_context(|| format!("Failed to parse settings file: {}", path.display()))
609 }
610
611 pub fn get_settings_path() -> Result<PathBuf> {
613 let home_dir = dirs::home_dir().context("Failed to determine home directory")?;
614
615 Ok(home_dir.join(".omni-dev").join("settings.json"))
616 }
617
618 pub fn get_env_var(&self, key: &str) -> Option<String> {
621 self.resolve_with(&SystemEnv, active_profile_from(&SystemEnv).as_deref(), key)
622 }
623
624 pub fn resolve_with<E: EnvSource>(
633 &self,
634 raw: &E,
635 active: Option<&str>,
636 key: &str,
637 ) -> Option<String> {
638 self.resolve_with_source(raw, active, key)
639 .map(|(value, _)| value)
640 }
641
642 pub fn resolve_pair_with<E: EnvSource>(
651 &self,
652 raw: &E,
653 active: Option<&str>,
654 key: &str,
655 file_key: &str,
656 ) -> (Option<String>, Option<String>) {
657 let raw_pair = (raw.var(key), raw.var(file_key));
658 if raw_pair.0.is_some() || raw_pair.1.is_some() {
659 return raw_pair;
660 }
661 let layer = match active {
662 Some(name) => self.profiles.get(name).map(|p| &p.env),
663 None => Some(&self.env),
664 };
665 layer.map_or((None, None), |env| {
666 (env.get(key).cloned(), env.get(file_key).cloned())
667 })
668 }
669
670 #[must_use]
674 pub fn env_source(&self) -> SettingsEnvRef<'_> {
675 SettingsEnvRef {
676 settings: self,
677 active_profile: active_profile_from(&SystemEnv),
678 }
679 }
680
681 pub fn resolve_with_source<E: EnvSource>(
689 &self,
690 raw: &E,
691 active: Option<&str>,
692 key: &str,
693 ) -> Option<(String, EnvValueSource)> {
694 if let Some(value) = raw.var(key) {
695 return Some((value, EnvValueSource::ProcessEnv));
696 }
697 match active {
698 Some(name) => self
699 .profiles
700 .get(name)
701 .and_then(|p| p.env.get(key).cloned())
702 .map(|value| (value, EnvValueSource::SettingsProfile(name.to_string()))),
703 None => self
704 .env
705 .get(key)
706 .cloned()
707 .map(|value| (value, EnvValueSource::SettingsEnv)),
708 }
709 }
710
711 pub fn upsert_env_vars(path: &Path, vars: &[(&str, &str)]) -> Result<()> {
715 Self::upsert_env_vars_in(path, None, vars)
716 }
717
718 pub fn upsert_env_vars_in(
733 path: &Path,
734 profile: Option<&str>,
735 vars: &[(&str, &str)],
736 ) -> Result<()> {
737 let mut settings_value = read_or_default_settings(path)?;
738
739 let env = ensure_env_object(&mut settings_value, profile)?;
740 for (key, value) in vars {
741 env.insert(
742 (*key).to_string(),
743 serde_json::Value::String((*value).to_string()),
744 );
745 if is_secret_env_var(key) {
749 env.remove(&secret_env::file_var_name(key));
750 }
751 }
752
753 write_settings(path, &settings_value)
754 }
755
756 pub fn remove_env_vars(path: &Path, keys: &[&str]) -> Result<bool> {
759 Self::remove_env_vars_in(path, None, keys)
760 }
761
762 pub fn remove_env_vars_in(path: &Path, profile: Option<&str>, keys: &[&str]) -> Result<bool> {
773 if !path.exists() {
774 return Ok(false);
775 }
776 let mut settings_value = read_or_default_settings(path)?;
777
778 let mut removed = false;
779 if let Some(env) = env_object_mut(&mut settings_value, profile) {
780 for key in keys {
781 if env.remove(*key).is_some() {
782 removed = true;
783 }
784 if is_secret_env_var(key) && env.remove(&secret_env::file_var_name(key)).is_some() {
787 removed = true;
788 }
789 }
790 }
791
792 if removed {
793 write_settings(path, &settings_value)?;
794 }
795 Ok(removed)
796 }
797
798 pub fn validate_profile(&self, name: &str) -> Result<()> {
802 if self.profiles.contains_key(name) {
803 return Ok(());
804 }
805 let known = if self.profiles.is_empty() {
806 "(none)".to_string()
807 } else {
808 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
809 names.sort_unstable();
810 names.join(", ")
811 };
812 Err(anyhow::anyhow!(
813 "unknown profile '{name}'; known profiles: {known}"
814 ))
815 }
816
817 pub fn upsert_gmail_account(
833 path: &Path,
834 account: &str,
835 vars: &[(&str, serde_json::Value)],
836 ) -> Result<()> {
837 upsert_account(path, "gmail", account, vars)
838 }
839
840 pub fn remove_gmail_account(path: &Path, account: &str) -> Result<bool> {
851 if !path.exists() {
852 return Ok(false);
853 }
854 let mut settings_value = read_or_default_settings(path)?;
855
856 let removed = object_at_mut(&mut settings_value, &["gmail", "accounts"])
857 .is_some_and(|accounts| accounts.remove(account).is_some());
858
859 if removed {
860 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
861 if gmail.get("default_account").and_then(|v| v.as_str()) == Some(account) {
862 gmail.remove("default_account");
863 }
864 }
865 write_settings(path, &settings_value)?;
866 }
867 Ok(removed)
868 }
869
870 pub fn set_gmail_default_account(path: &Path, account: Option<&str>) -> Result<()> {
876 let mut settings_value = read_or_default_settings(path)?;
877
878 match account {
879 Some(name) => {
880 let gmail = ensure_object_at(&mut settings_value, &["gmail"])?;
881 gmail.insert(
882 "default_account".to_string(),
883 serde_json::Value::String(name.to_string()),
884 );
885 }
886 None => {
887 if let Some(gmail) = object_at_mut(&mut settings_value, &["gmail"]) {
888 gmail.remove("default_account");
889 }
890 }
891 }
892
893 write_settings(path, &settings_value)
894 }
895
896 pub fn upsert_drive_account(
908 path: &Path,
909 account: &str,
910 vars: &[(&str, serde_json::Value)],
911 ) -> Result<()> {
912 upsert_account(path, "drive", account, vars)
913 }
914
915 pub fn remove_drive_account(path: &Path, account: &str) -> Result<bool> {
925 if !path.exists() {
926 return Ok(false);
927 }
928 let mut settings_value = read_or_default_settings(path)?;
929
930 let removed = object_at_mut(&mut settings_value, &["drive", "accounts"])
931 .is_some_and(|accounts| accounts.remove(account).is_some());
932
933 if removed {
934 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
935 if drive.get("default_account").and_then(|v| v.as_str()) == Some(account) {
936 drive.remove("default_account");
937 }
938 }
939 write_settings(path, &settings_value)?;
940 }
941 Ok(removed)
942 }
943
944 pub fn set_drive_default_account(path: &Path, account: Option<&str>) -> Result<()> {
949 let mut settings_value = read_or_default_settings(path)?;
950
951 match account {
952 Some(name) => {
953 let drive = ensure_object_at(&mut settings_value, &["drive"])?;
954 drive.insert(
955 "default_account".to_string(),
956 serde_json::Value::String(name.to_string()),
957 );
958 }
959 None => {
960 if let Some(drive) = object_at_mut(&mut settings_value, &["drive"]) {
961 drive.remove("default_account");
962 }
963 }
964 }
965
966 write_settings(path, &settings_value)
967 }
968}
969
970fn ensure_env_object<'a>(
974 root: &'a mut serde_json::Value,
975 profile: Option<&str>,
976) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
977 match profile {
978 Some(name) => ensure_object_at(root, &["profiles", name, "env"]),
979 None => ensure_object_at(root, &["env"]),
980 }
981}
982
983fn env_object_mut<'a>(
987 root: &'a mut serde_json::Value,
988 profile: Option<&str>,
989) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
990 match profile {
991 Some(name) => object_at_mut(root, &["profiles", name, "env"]),
992 None => object_at_mut(root, &["env"]),
993 }
994}
995
996fn ensure_object_at<'a>(
1004 root: &'a mut serde_json::Value,
1005 segments: &[&str],
1006) -> Result<&'a mut serde_json::Map<String, serde_json::Value>> {
1007 let mut current = root;
1008 for segment in segments {
1009 if !current
1010 .get(*segment)
1011 .is_some_and(serde_json::Value::is_object)
1012 {
1013 current[*segment] = serde_json::json!({});
1014 }
1015 current = current
1016 .get_mut(*segment)
1017 .context("Internal error: target key missing immediately after being created")?;
1018 }
1019 current
1020 .as_object_mut()
1021 .context("Internal error: target key is not an object after initialization")
1022}
1023
1024fn object_at_mut<'a>(
1028 root: &'a mut serde_json::Value,
1029 segments: &[&str],
1030) -> Option<&'a mut serde_json::Map<String, serde_json::Value>> {
1031 let mut current = root;
1032 for segment in segments {
1033 current = current.get_mut(*segment)?;
1034 }
1035 current.as_object_mut()
1036}
1037
1038fn read_or_default_settings(path: &Path) -> Result<serde_json::Value> {
1041 if path.exists() {
1042 let content = fs::read_to_string(path)
1043 .with_context(|| format!("Failed to read {}", path.display()))?;
1044 serde_json::from_str(&content)
1045 .with_context(|| format!("Failed to parse {}", path.display()))
1046 } else {
1047 Ok(serde_json::json!({}))
1048 }
1049}
1050
1051const ACCOUNT_SECRET_FIELDS: &[&str] = &["client_secret", "refresh_token"];
1054
1055fn upsert_account(
1072 path: &Path,
1073 section: &str,
1074 account: &str,
1075 vars: &[(&str, serde_json::Value)],
1076) -> Result<()> {
1077 let mut settings_value = read_or_default_settings(path)?;
1078
1079 let entry = ensure_object_at(&mut settings_value, &[section, "accounts", account])?;
1080 let stored_client_id = entry
1081 .get("client_id")
1082 .and_then(serde_json::Value::as_str)
1083 .map(str::to_string);
1084 let mut secret_writes = Vec::new();
1085 for (key, value) in vars {
1086 if let Some(file) = account_secret_file(entry, key, value) {
1087 let label = format!("{section}.accounts.{account}.{key}_file");
1088 let secret = crate::utils::secret::Secret::new(value.as_str().unwrap_or_default());
1089 if let Some(write) =
1090 secret_env::plan_secret_file_write(&label, Path::new(&file), &secret)?
1091 {
1092 if *key == "client_secret" && write.replaces_existing() {
1093 refuse_client_change(&label, stored_client_id.as_deref(), vars)?;
1094 }
1095 secret_writes.push((write, secret));
1096 } entry.remove(*key);
1098 } else {
1099 entry.insert((*key).to_string(), value.clone());
1100 }
1101 }
1102
1103 for (write, secret) in secret_writes {
1104 write.write(&secret)?;
1105 }
1106 write_settings(path, &settings_value)
1107}
1108
1109fn refuse_client_change(
1113 label: &str,
1114 stored: Option<&str>,
1115 vars: &[(&str, serde_json::Value)],
1116) -> Result<()> {
1117 let incoming = vars
1118 .iter()
1119 .find(|(key, _)| *key == "client_id")
1120 .and_then(|(_, value)| value.as_str());
1121 if let (Some(stored), Some(incoming)) = (stored, incoming) {
1122 if stored != incoming {
1123 anyhow::bail!(
1124 "{label} holds the secret of OAuth client {stored}, not {incoming}; refusing to \
1125 replace it, since other accounts may share it. Point {label} at a file of its \
1126 own, or remove it to store the secret in settings.json"
1127 );
1128 }
1129 }
1130 Ok(())
1131}
1132
1133pub(crate) fn account_secret(
1142 section: &str,
1143 account: &str,
1144 field: &str,
1145 value: Option<&str>,
1146 file: Option<&str>,
1147) -> std::result::Result<Option<crate::utils::secret::Secret>, secret_env::SecretEnvError> {
1148 let name = format!("{section}.accounts.{account}.{field}");
1149 secret_env::resolve_secret_pair(&name, value, &format!("{name}_file"), file)
1150}
1151
1152#[cfg(feature = "mcp")]
1156pub(crate) fn account_secret_is_set(value: Option<&str>, file: Option<&str>) -> bool {
1157 value.is_some_and(|v| !v.is_empty()) || file.is_some_and(|f| !f.is_empty())
1158}
1159
1160fn account_secret_file(
1163 entry: &serde_json::Map<String, serde_json::Value>,
1164 key: &str,
1165 value: &serde_json::Value,
1166) -> Option<String> {
1167 if !ACCOUNT_SECRET_FIELDS.contains(&key) || !value.is_string() {
1168 return None;
1169 }
1170 entry
1171 .get(&format!("{key}_file"))
1172 .and_then(serde_json::Value::as_str)
1173 .filter(|file| !file.is_empty())
1174 .map(str::to_string)
1175}
1176
1177fn write_settings(path: &Path, value: &serde_json::Value) -> Result<()> {
1182 if let Some(parent) = path.parent() {
1183 if !parent.as_os_str().is_empty() {
1184 crate::daemon::paths::ensure_dir_0700(parent)?;
1185 }
1186 }
1187 let formatted =
1188 serde_json::to_string_pretty(value).context("Failed to serialize settings JSON")?;
1189 write_file_0600(path, &formatted)
1190 .with_context(|| format!("Failed to write {}", path.display()))?;
1191 crate::daemon::paths::set_file_0600(path)?;
1192 Ok(())
1193}
1194
1195#[cfg(unix)]
1197fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1198 use std::io::Write;
1199 use std::os::unix::fs::OpenOptionsExt;
1200
1201 let mut file = fs::OpenOptions::new()
1202 .write(true)
1203 .create(true)
1204 .truncate(true)
1205 .mode(0o600)
1206 .open(path)?;
1207 file.write_all(contents.as_bytes())
1208}
1209
1210#[cfg(not(unix))]
1213fn write_file_0600(path: &Path, contents: &str) -> std::io::Result<()> {
1214 fs::write(path, contents)
1215}
1216
1217pub fn get_env_var(key: &str) -> Result<String> {
1220 get_env_var_with(&SystemEnv, Settings::load, key)
1221}
1222
1223pub fn get_env_var_sourced(key: &str) -> Result<(String, EnvValueSource)> {
1230 get_env_var_sourced_with(&SystemEnv, Settings::load, exported_by_cli_flag(key), key)
1231}
1232
1233fn get_env_var_with<E, F>(env: &E, load: F, key: &str) -> Result<String>
1236where
1237 E: EnvSource,
1238 F: FnOnce() -> Result<Settings>,
1239{
1240 get_env_var_sourced_with(env, load, false, key).map(|(value, _)| value)
1241}
1242
1243fn get_env_var_sourced_with<E, F>(
1251 env: &E,
1252 load: F,
1253 from_cli_flag: bool,
1254 key: &str,
1255) -> Result<(String, EnvValueSource)>
1256where
1257 E: EnvSource,
1258 F: FnOnce() -> Result<Settings>,
1259{
1260 if let Some(value) = env.var(key) {
1264 let source = if from_cli_flag {
1265 EnvValueSource::CliFlag
1266 } else {
1267 EnvValueSource::ProcessEnv
1268 };
1269 return Ok((value, source));
1270 }
1271 match load() {
1272 Ok(settings) => settings
1273 .resolve_with_source(env, active_profile_from(env).as_deref(), key)
1274 .ok_or_else(|| anyhow::anyhow!("Environment variable not found: {key}")),
1275 Err(err) => {
1276 Err(anyhow::anyhow!("Environment variable not found: {key}").context(err))
1278 }
1279 }
1280}
1281
1282pub fn get_env_vars(keys: &[&str]) -> Result<String> {
1284 for key in keys {
1285 if let Ok(value) = get_env_var(key) {
1286 return Ok(value);
1287 }
1288 }
1289
1290 Err(anyhow::anyhow!(
1291 "None of the environment variables found: {keys:?}"
1292 ))
1293}
1294
1295#[cfg(test)]
1296#[allow(clippy::unwrap_used, clippy::expect_used)]
1297mod tests {
1298 use super::*;
1299 use crate::test_support::env::MapEnv;
1300 use std::env;
1301 use std::fs;
1302 use tempfile::TempDir;
1303
1304 fn settings_with_profile() -> Settings {
1307 let mut base = HashMap::new();
1308 base.insert("ATLASSIAN_EMAIL".to_string(), "base@x.com".to_string());
1309 base.insert("SHARED".to_string(), "base-shared".to_string());
1310
1311 let mut work_env = HashMap::new();
1312 work_env.insert("ATLASSIAN_EMAIL".to_string(), "me@work.com".to_string());
1313
1314 let mut profiles = HashMap::new();
1315 profiles.insert("work".to_string(), Profile { env: work_env });
1316
1317 Settings {
1318 env: base,
1319 profiles,
1320 ..Settings::default()
1321 }
1322 }
1323
1324 #[test]
1325 fn settings_load_from_path() {
1326 let temp_dir = {
1328 std::fs::create_dir_all("tmp").ok();
1329 TempDir::new_in("tmp").unwrap()
1330 };
1331 let settings_path = temp_dir.path().join("settings.json");
1332
1333 let settings_json = r#"{
1335 "env": {
1336 "TEST_VAR": "test_value",
1337 "CLAUDE_API_KEY": "test_api_key"
1338 }
1339 }"#;
1340 fs::write(&settings_path, settings_json).unwrap();
1341
1342 let settings = Settings::load_from_path(&settings_path).unwrap();
1344
1345 assert_eq!(settings.env.get("TEST_VAR").unwrap(), "test_value");
1347 assert_eq!(settings.env.get("CLAUDE_API_KEY").unwrap(), "test_api_key");
1348 }
1349
1350 #[test]
1351 fn load_warn_dedup_warns_once_per_distinct_failure() {
1352 let dedup = LoadWarnDedup::new();
1353 assert!(dedup.observe(Some("broken A")));
1354 assert!(!dedup.observe(Some("broken A")), "a repeat is silent");
1355 assert!(dedup.observe(Some("broken B")), "a new failure warns");
1356 assert!(!dedup.observe(Some("broken B")));
1357 }
1358
1359 #[test]
1360 fn load_warn_dedup_rewarns_after_a_successful_load() {
1361 let dedup = LoadWarnDedup::new();
1364 assert!(dedup.observe(Some("broken")));
1365 assert!(!dedup.observe(None), "a success never warns");
1366 assert!(dedup.observe(Some("broken")));
1367 }
1368
1369 #[test]
1370 fn load_or_warn_default_warns_and_falls_back_when_settings_json_fails_to_parse() {
1371 let guard = crate::drive::test_support::EnvGuard::take();
1376 let dir = guard.clear_credentials();
1377 let settings_dir = dir.path().join(".omni-dev");
1378 fs::create_dir_all(&settings_dir).unwrap();
1379 fs::write(settings_dir.join("settings.json"), "{not valid json").unwrap();
1380
1381 let logs = crate::test_support::capture_at(tracing::Level::WARN, || {
1382 let settings = Settings::load_or_warn_default();
1383 assert!(settings.env.is_empty());
1384 assert!(settings.profiles.is_empty());
1385 assert!(settings.drive.accounts.is_empty());
1386 });
1387 assert!(logs.contains("settings.json"), "{logs}");
1388 }
1389
1390 #[test]
1391 fn settings_get_env_var() {
1392 let temp_dir = {
1394 std::fs::create_dir_all("tmp").ok();
1395 TempDir::new_in("tmp").unwrap()
1396 };
1397 let settings_path = temp_dir.path().join("settings.json");
1398
1399 let settings_json = r#"{
1401 "env": {
1402 "TEST_VAR": "test_value",
1403 "CLAUDE_API_KEY": "test_api_key"
1404 }
1405 }"#;
1406 fs::write(&settings_path, settings_json).unwrap();
1407
1408 let settings = Settings::load_from_path(&settings_path).unwrap();
1410
1411 env::set_var("TEST_VAR_ENV", "env_value");
1413
1414 env::set_var("TEST_VAR", "env_override");
1416 assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "env_override");
1417
1418 env::remove_var("TEST_VAR"); assert_eq!(settings.get_env_var("TEST_VAR").unwrap(), "test_value");
1421
1422 assert_eq!(settings.get_env_var("TEST_VAR_ENV").unwrap(), "env_value");
1424
1425 env::remove_var("TEST_VAR_ENV");
1427 }
1428
1429 #[test]
1432 fn resolve_no_profile_uses_base_env() {
1433 let settings = settings_with_profile();
1434 let raw = MapEnv::new();
1435 assert_eq!(
1436 settings
1437 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1438 .as_deref(),
1439 Some("base@x.com")
1440 );
1441 }
1442
1443 #[test]
1444 fn resolve_active_profile_uses_profile_env() {
1445 let settings = settings_with_profile();
1446 let raw = MapEnv::new();
1447 assert_eq!(
1448 settings
1449 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1450 .as_deref(),
1451 Some("me@work.com")
1452 );
1453 }
1454
1455 #[test]
1456 fn resolve_active_profile_does_not_consult_base() {
1457 let settings = settings_with_profile();
1460 let raw = MapEnv::new();
1461 assert_eq!(settings.resolve_with(&raw, Some("work"), "SHARED"), None);
1462 }
1463
1464 #[test]
1465 fn resolve_process_env_wins_over_profile_and_base() {
1466 let settings = settings_with_profile();
1467 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1468 assert_eq!(
1469 settings
1470 .resolve_with(&raw, Some("work"), "ATLASSIAN_EMAIL")
1471 .as_deref(),
1472 Some("cli@x.com")
1473 );
1474 assert_eq!(
1475 settings
1476 .resolve_with(&raw, None, "ATLASSIAN_EMAIL")
1477 .as_deref(),
1478 Some("cli@x.com")
1479 );
1480 }
1481
1482 #[test]
1483 fn resolve_unknown_active_profile_yields_none() {
1484 let settings = settings_with_profile();
1487 let raw = MapEnv::new();
1488 assert_eq!(
1489 settings.resolve_with(&raw, Some("nope"), "ATLASSIAN_EMAIL"),
1490 None
1491 );
1492 }
1493
1494 #[test]
1497 fn upsert_of_a_secret_replaces_its_file_companion_in_the_same_map() {
1498 let (_tmp, path) = temp_settings_path();
1499 fs::create_dir_all(path.parent().unwrap()).unwrap();
1500 fs::write(
1501 &path,
1502 r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k", "OTHER_FILE": "/keep"},
1503 "profiles": {"work": {"env": {"DATADOG_API_KEY_FILE": "/w"}}}}"#,
1504 )
1505 .unwrap();
1506
1507 Settings::upsert_env_vars_in(&path, None, &[("DATADOG_API_KEY", "v")]).unwrap();
1508
1509 let val = read_json(&path);
1510 assert_eq!(val["env"]["DATADOG_API_KEY"], "v");
1511 assert!(val["env"].get("DATADOG_API_KEY_FILE").is_none());
1512 assert_eq!(val["env"]["OTHER_FILE"], "/keep");
1514 assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY_FILE"], "/w");
1515 }
1516
1517 #[test]
1518 fn remove_of_a_secret_also_removes_its_file_companion() {
1519 let (_tmp, path) = temp_settings_path();
1520 fs::create_dir_all(path.parent().unwrap()).unwrap();
1521 fs::write(&path, r#"{"env": {"DATADOG_API_KEY_FILE": "/run/k"}}"#).unwrap();
1522
1523 assert!(Settings::remove_env_vars_in(&path, None, &["DATADOG_API_KEY"]).unwrap());
1524 assert!(read_json(&path)["env"]
1525 .get("DATADOG_API_KEY_FILE")
1526 .is_none());
1527 }
1528
1529 fn settings_with_env(base: &[(&str, &str)], work: &[(&str, &str)]) -> Settings {
1530 let map = |pairs: &[(&str, &str)]| {
1531 pairs
1532 .iter()
1533 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1534 .collect::<HashMap<_, _>>()
1535 };
1536 let mut profiles = HashMap::new();
1537 profiles.insert("work".to_string(), Profile { env: map(work) });
1538 Settings {
1539 env: map(base),
1540 profiles,
1541 ..Settings::default()
1542 }
1543 }
1544
1545 #[test]
1546 fn resolve_pair_takes_the_process_env_pair_over_settings() {
1547 let settings = settings_with_env(&[("K", "from-settings")], &[]);
1550 let raw = MapEnv::new().with("K_FILE", "/run/k");
1551 assert_eq!(
1552 settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1553 (None, Some("/run/k".to_string()))
1554 );
1555 }
1556
1557 #[test]
1558 fn resolve_pair_falls_back_to_one_settings_layer_as_a_pair() {
1559 let settings = settings_with_env(&[("K", "v"), ("K_FILE", "/f")], &[("K_FILE", "/w")]);
1560 let raw = MapEnv::new();
1561 assert_eq!(
1563 settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1564 (Some("v".to_string()), Some("/f".to_string()))
1565 );
1566 assert_eq!(
1568 settings.resolve_pair_with(&raw, Some("work"), "K", "K_FILE"),
1569 (None, Some("/w".to_string()))
1570 );
1571 assert_eq!(
1572 settings.resolve_pair_with(&raw, Some("nope"), "K", "K_FILE"),
1573 (None, None)
1574 );
1575 }
1576
1577 #[test]
1578 fn resolve_pair_empty_process_env_value_neutralises_settings() {
1579 let settings = settings_with_env(&[("K", "from-settings")], &[]);
1581 let raw = MapEnv::new().with("K", "");
1582 assert_eq!(
1583 settings.resolve_pair_with(&raw, None, "K", "K_FILE"),
1584 (Some(String::new()), None)
1585 );
1586 }
1587
1588 #[test]
1589 fn secret_resolver_over_settings_layers() {
1590 use crate::utils::secret_env::{secret_var, SecretEnvError};
1591 let dir = tempfile::tempdir().unwrap();
1592 let path = dir.path().join("key");
1593 std::fs::write(&path, "from-file\n").unwrap();
1594 #[cfg(unix)]
1595 {
1596 use std::os::unix::fs::PermissionsExt;
1597 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
1598 }
1599 let path = path.to_str().unwrap();
1600
1601 struct Layered<'a>(&'a Settings, MapEnv);
1602 impl EnvSource for Layered<'_> {
1603 fn var(&self, key: &str) -> Option<String> {
1604 self.0.resolve_with(&self.1, None, key)
1605 }
1606 fn var_pair(&self, key: &str, file_key: &str) -> (Option<String>, Option<String>) {
1607 self.0.resolve_pair_with(&self.1, None, key, file_key)
1608 }
1609 }
1610
1611 let settings = settings_with_env(&[("DATADOG_API_KEY", "from-settings")], &[]);
1613 let env = Layered(&settings, MapEnv::new().with("DATADOG_API_KEY_FILE", path));
1614 let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1615 assert_eq!(got.expose_secret(), "from-file");
1616
1617 let settings = settings_with_env(&[("DATADOG_API_KEY_FILE", path)], &[]);
1619 let env = Layered(&settings, MapEnv::new());
1620 let got = secret_var(&env, "DATADOG_API_KEY").unwrap().unwrap();
1621 assert_eq!(got.expose_secret(), "from-file");
1622
1623 let settings = settings_with_env(
1625 &[("DATADOG_API_KEY", "v"), ("DATADOG_API_KEY_FILE", path)],
1626 &[],
1627 );
1628 let env = Layered(&settings, MapEnv::new());
1629 assert!(matches!(
1630 secret_var(&env, "DATADOG_API_KEY").unwrap_err(),
1631 SecretEnvError::Conflict { .. }
1632 ));
1633 assert_eq!(env.var("DATADOG_API_KEY"), Some("v".to_string()));
1636 }
1637
1638 #[test]
1639 fn settings_env_ref_var_falls_back_to_the_settings_layer() {
1640 let settings = settings_with_env(
1645 &[("OMNI_DEV_TEST_SETTINGS_ENV_REF_K", "from-settings")],
1646 &[],
1647 );
1648 let env_ref = settings.env_source();
1649 assert_eq!(
1650 env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_K"),
1651 Some("from-settings".to_string())
1652 );
1653 assert_eq!(env_ref.var("OMNI_DEV_TEST_SETTINGS_ENV_REF_MISSING"), None);
1654 }
1655
1656 #[test]
1659 fn resolve_with_source_process_env_is_process_env() {
1660 let settings = settings_with_profile();
1661 let raw = MapEnv::new().with("ATLASSIAN_EMAIL", "cli@x.com");
1662 assert_eq!(
1663 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1664 Some(("cli@x.com".to_string(), EnvValueSource::ProcessEnv))
1665 );
1666 }
1667
1668 #[test]
1669 fn resolve_with_source_base_env_is_settings_env() {
1670 let settings = settings_with_profile();
1671 let raw = MapEnv::new();
1672 assert_eq!(
1673 settings.resolve_with_source(&raw, None, "ATLASSIAN_EMAIL"),
1674 Some(("base@x.com".to_string(), EnvValueSource::SettingsEnv))
1675 );
1676 }
1677
1678 #[test]
1679 fn resolve_with_source_profile_env_names_profile() {
1680 let settings = settings_with_profile();
1681 let raw = MapEnv::new();
1682 assert_eq!(
1683 settings.resolve_with_source(&raw, Some("work"), "ATLASSIAN_EMAIL"),
1684 Some((
1685 "me@work.com".to_string(),
1686 EnvValueSource::SettingsProfile("work".to_string())
1687 ))
1688 );
1689 }
1690
1691 #[test]
1692 fn resolve_with_source_missing_key_is_none() {
1693 let settings = settings_with_profile();
1694 let raw = MapEnv::new();
1695 assert_eq!(settings.resolve_with_source(&raw, None, "MISSING"), None);
1696 }
1697
1698 #[test]
1699 fn env_value_source_display_names_each_layer() {
1700 assert_eq!(EnvValueSource::CliFlag.to_string(), "command-line flag");
1701 assert_eq!(
1702 EnvValueSource::ProcessEnv.to_string(),
1703 "process environment variable (e.g. a shell export)"
1704 );
1705 assert_eq!(
1706 EnvValueSource::SettingsEnv.to_string(),
1707 "the env map in $HOME/.omni-dev/settings.json"
1708 );
1709 assert_eq!(
1710 EnvValueSource::SettingsProfile("work".to_string()).to_string(),
1711 "the profile 'work' env map in $HOME/.omni-dev/settings.json"
1712 );
1713 }
1714
1715 #[test]
1716 fn active_profile_from_reads_and_trims_empty() {
1717 assert_eq!(active_profile_from(&MapEnv::new()), None);
1718 assert_eq!(
1719 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "")),
1720 None
1721 );
1722 assert_eq!(
1723 active_profile_from(&MapEnv::new().with(PROFILE_ENV_VAR, "work")).as_deref(),
1724 Some("work")
1725 );
1726 }
1727
1728 #[test]
1729 fn profile_suffix_names_profile_or_is_empty() {
1730 assert_eq!(profile_suffix(None), "");
1731 assert_eq!(profile_suffix(Some("work")), " (profile 'work')");
1732 }
1733
1734 #[test]
1735 fn validate_profile_accepts_known() {
1736 assert!(settings_with_profile().validate_profile("work").is_ok());
1737 }
1738
1739 #[test]
1740 fn validate_profile_rejects_unknown_and_lists_sorted() {
1741 let mut settings = settings_with_profile();
1742 settings
1743 .profiles
1744 .insert("personal".to_string(), Profile::default());
1745 let err = settings.validate_profile("wrok").unwrap_err().to_string();
1746 assert_eq!(
1747 err,
1748 "unknown profile 'wrok'; known profiles: personal, work"
1749 );
1750 }
1751
1752 #[test]
1753 fn validate_profile_reports_none_when_empty() {
1754 let settings = Settings::default();
1755 let err = settings.validate_profile("work").unwrap_err().to_string();
1756 assert_eq!(err, "unknown profile 'work'; known profiles: (none)");
1757 }
1758
1759 #[test]
1760 fn settings_parse_profiles_from_json() {
1761 let json = r#"{
1762 "env": { "BASE": "b" },
1763 "profiles": {
1764 "work": { "env": { "ATLASSIAN_EMAIL": "me@work.com" } }
1765 }
1766 }"#;
1767 let settings: Settings = serde_json::from_str(json).unwrap();
1768 assert_eq!(settings.env.get("BASE").unwrap(), "b");
1769 assert_eq!(
1770 settings
1771 .profiles
1772 .get("work")
1773 .unwrap()
1774 .env
1775 .get("ATLASSIAN_EMAIL")
1776 .unwrap(),
1777 "me@work.com"
1778 );
1779 }
1780
1781 #[test]
1782 fn settings_without_profiles_key_defaults_empty() {
1783 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1784 assert!(settings.profiles.is_empty());
1785 }
1786
1787 #[test]
1788 fn settings_parse_mcp_section_from_json() {
1789 let json = r#"{
1790 "mcp": {
1791 "default_model": "claude-sonnet-4-6",
1792 "log_level": "info",
1793 "max_response_bytes": 204800
1794 }
1795 }"#;
1796 let settings: Settings = serde_json::from_str(json).unwrap();
1797 assert_eq!(
1798 settings.mcp.default_model.as_deref(),
1799 Some("claude-sonnet-4-6")
1800 );
1801 assert_eq!(settings.mcp.log_level.as_deref(), Some("info"));
1802 assert_eq!(settings.mcp.max_response_bytes, Some(204_800));
1803 }
1804
1805 #[test]
1806 fn settings_without_mcp_key_defaults_all_none() {
1807 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1810 assert!(settings.mcp.default_model.is_none());
1811 assert!(settings.mcp.log_level.is_none());
1812 assert!(settings.mcp.max_response_bytes.is_none());
1813 }
1814
1815 #[test]
1816 fn settings_mcp_partial_section_leaves_others_none() {
1817 let settings: Settings =
1819 serde_json::from_str(r#"{ "mcp": { "log_level": "debug" } }"#).unwrap();
1820 assert_eq!(settings.mcp.log_level.as_deref(), Some("debug"));
1821 assert!(settings.mcp.default_model.is_none());
1822 assert!(settings.mcp.max_response_bytes.is_none());
1823 }
1824
1825 #[test]
1826 fn settings_parse_gmail_section_from_json() {
1827 let json = r#"{
1828 "gmail": {
1829 "default_account": "work",
1830 "accounts": {
1831 "work": {
1832 "client_id": "id",
1833 "client_secret": "secret",
1834 "refresh_token": "token",
1835 "scope": "https://www.googleapis.com/auth/gmail.modify",
1836 "email_address": "alice@work.com"
1837 }
1838 }
1839 }
1840 }"#;
1841 let settings: Settings = serde_json::from_str(json).unwrap();
1842 assert_eq!(settings.gmail.default_account.as_deref(), Some("work"));
1843 let account = settings.gmail.accounts.get("work").unwrap();
1844 assert_eq!(account.client_id.as_deref(), Some("id"));
1845 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1846 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1847 assert_eq!(
1848 account.scope.as_deref(),
1849 Some("https://www.googleapis.com/auth/gmail.modify")
1850 );
1851 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1852 }
1853
1854 #[test]
1855 fn settings_without_gmail_key_defaults_empty() {
1856 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1857 assert!(settings.gmail.default_account.is_none());
1858 assert!(settings.gmail.accounts.is_empty());
1859 }
1860
1861 #[test]
1862 fn settings_parse_drive_section_from_json() {
1863 let json = r#"{
1864 "drive": {
1865 "default_account": "work",
1866 "accounts": {
1867 "work": {
1868 "client_id": "id",
1869 "client_secret": "secret",
1870 "refresh_token": "token",
1871 "scope": "https://www.googleapis.com/auth/drive.readonly",
1872 "email_address": "alice@work.com"
1873 }
1874 }
1875 }
1876 }"#;
1877 let settings: Settings = serde_json::from_str(json).unwrap();
1878 assert_eq!(settings.drive.default_account.as_deref(), Some("work"));
1879 let account = settings.drive.accounts.get("work").unwrap();
1880 assert_eq!(account.client_id.as_deref(), Some("id"));
1881 assert_eq!(account.client_secret.as_deref(), Some("secret"));
1882 assert_eq!(account.refresh_token.as_deref(), Some("token"));
1883 assert_eq!(
1884 account.scope.as_deref(),
1885 Some("https://www.googleapis.com/auth/drive.readonly")
1886 );
1887 assert_eq!(account.email_address.as_deref(), Some("alice@work.com"));
1888 }
1889
1890 #[test]
1891 fn settings_without_drive_key_defaults_empty() {
1892 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1893 assert!(settings.drive.default_account.is_none());
1894 assert!(settings.drive.accounts.is_empty());
1895 }
1896
1897 #[test]
1898 fn settings_parse_lease_section_from_json() {
1899 let json = r#"{
1900 "lease": {
1901 "default_expiry_minutes": 45,
1902 "backup_dir": "/tmp/drive-backups",
1903 "biometrics_only": true,
1904 "allow_headless": true
1905 }
1906 }"#;
1907 let settings: Settings = serde_json::from_str(json).unwrap();
1908 assert_eq!(settings.lease.default_expiry_minutes, Some(45));
1909 assert_eq!(
1910 settings.lease.backup_dir.as_deref(),
1911 Some(Path::new("/tmp/drive-backups"))
1912 );
1913 assert!(settings.lease.biometrics_only);
1914 assert!(settings.lease.allow_headless);
1915 }
1916
1917 #[test]
1918 fn settings_without_lease_key_defaults_all_unset() {
1919 let settings: Settings = serde_json::from_str(r#"{ "env": {} }"#).unwrap();
1923 assert!(settings.lease.default_expiry_minutes.is_none());
1924 assert!(settings.lease.backup_dir.is_none());
1925 assert!(!settings.lease.biometrics_only);
1926 assert!(!settings.lease.allow_headless);
1927 }
1928
1929 #[test]
1930 fn settings_lease_partial_section_leaves_others_default() {
1931 let settings: Settings =
1932 serde_json::from_str(r#"{ "lease": { "biometrics_only": true } }"#).unwrap();
1933 assert!(settings.lease.biometrics_only);
1934 assert!(settings.lease.default_expiry_minutes.is_none());
1935 assert!(settings.lease.backup_dir.is_none());
1936 assert!(!settings.lease.allow_headless);
1937 }
1938
1939 #[test]
1942 fn get_env_var_with_returns_raw_hit_without_loading() {
1943 let env = MapEnv::new().with("K", "v");
1944 let value = get_env_var_with(&env, || panic!("must not load settings"), "K").unwrap();
1945 assert_eq!(value, "v");
1946 }
1947
1948 #[test]
1949 fn get_env_var_with_falls_back_to_base_settings() {
1950 let settings = settings_with_profile();
1951 let env = MapEnv::new();
1952 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1953 assert_eq!(value, "base@x.com");
1954 }
1955
1956 #[test]
1957 fn get_env_var_with_honours_active_profile() {
1958 let settings = settings_with_profile();
1959 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
1960 let value = get_env_var_with(&env, || Ok(settings), "ATLASSIAN_EMAIL").unwrap();
1961 assert_eq!(value, "me@work.com");
1962 }
1963
1964 #[test]
1965 fn get_env_var_with_missing_key_is_not_found() {
1966 let env = MapEnv::new();
1967 let err = get_env_var_with(&env, || Ok(Settings::default()), "MISSING")
1968 .unwrap_err()
1969 .to_string();
1970 assert!(err.contains("Environment variable not found: MISSING"));
1971 }
1972
1973 #[test]
1974 fn get_env_var_with_load_error_maps_to_not_found() {
1975 let env = MapEnv::new();
1976 let err =
1977 get_env_var_with(&env, || Err(anyhow::anyhow!("disk boom")), "MISSING").unwrap_err();
1978 assert_eq!(err.to_string(), "disk boom");
1981 let chain = format!("{err:#}");
1982 assert!(chain.contains("Environment variable not found: MISSING"));
1983 }
1984
1985 #[test]
1988 fn get_env_var_sourced_with_raw_hit_is_process_env() {
1989 let env = MapEnv::new().with("K", "v");
1990 let resolved =
1991 get_env_var_sourced_with(&env, || panic!("must not load settings"), false, "K")
1992 .unwrap();
1993 assert_eq!(resolved, ("v".to_string(), EnvValueSource::ProcessEnv));
1994 }
1995
1996 #[test]
1997 fn get_env_var_sourced_with_flag_export_is_cli_flag() {
1998 let env = MapEnv::new().with("K", "true");
1999 let resolved =
2000 get_env_var_sourced_with(&env, || panic!("must not load settings"), true, "K").unwrap();
2001 assert_eq!(resolved, ("true".to_string(), EnvValueSource::CliFlag));
2002 }
2003
2004 #[test]
2005 fn get_env_var_sourced_with_falls_back_to_settings_sources() {
2006 let settings = settings_with_profile();
2007 let env = MapEnv::new();
2008 let resolved =
2009 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2010 assert_eq!(
2011 resolved,
2012 ("base@x.com".to_string(), EnvValueSource::SettingsEnv)
2013 );
2014
2015 let settings = settings_with_profile();
2016 let env = MapEnv::new().with(PROFILE_ENV_VAR, "work");
2017 let resolved =
2018 get_env_var_sourced_with(&env, || Ok(settings), false, "ATLASSIAN_EMAIL").unwrap();
2019 assert_eq!(
2020 resolved,
2021 (
2022 "me@work.com".to_string(),
2023 EnvValueSource::SettingsProfile("work".to_string())
2024 )
2025 );
2026 }
2027
2028 #[test]
2029 fn cli_flag_export_registry_roundtrip() {
2030 const KEY: &str = "OMNI_DEV_TEST_1143_REGISTRY_ROUNDTRIP";
2033 assert!(!exported_by_cli_flag(KEY));
2034 note_cli_flag_export(KEY);
2035 assert!(exported_by_cli_flag(KEY));
2036 }
2037
2038 fn temp_settings_path() -> (TempDir, std::path::PathBuf) {
2043 let temp_dir = {
2044 std::fs::create_dir_all("tmp").ok();
2045 TempDir::new_in("tmp").unwrap()
2046 };
2047 let path = temp_dir.path().join(".omni-dev").join("settings.json");
2048 (temp_dir, path)
2049 }
2050
2051 fn read_json(path: &Path) -> serde_json::Value {
2052 serde_json::from_str(&fs::read_to_string(path).unwrap()).unwrap()
2053 }
2054
2055 #[test]
2056 fn upsert_env_vars_creates_file_and_dir_with_secure_permissions() {
2057 let (_tmp, path) = temp_settings_path();
2058
2059 Settings::upsert_env_vars(&path, &[("A_KEY", "a"), ("B_KEY", "b")]).unwrap();
2060
2061 let val = read_json(&path);
2062 assert_eq!(val["env"]["A_KEY"], "a");
2063 assert_eq!(val["env"]["B_KEY"], "b");
2064
2065 #[cfg(unix)]
2067 {
2068 use std::os::unix::fs::PermissionsExt;
2069 let dir_mode = fs::metadata(path.parent().unwrap())
2070 .unwrap()
2071 .permissions()
2072 .mode();
2073 assert_eq!(dir_mode & 0o777, 0o700);
2074 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2075 assert_eq!(file_mode & 0o777, 0o600);
2076 }
2077 }
2078
2079 #[test]
2080 fn upsert_env_vars_merges_and_preserves_unknown_fields() {
2081 let (_tmp, path) = temp_settings_path();
2082 fs::create_dir_all(path.parent().unwrap()).unwrap();
2083 fs::write(&path, r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#).unwrap();
2084
2085 Settings::upsert_env_vars(&path, &[("A_KEY", "new")]).unwrap();
2086
2087 let val = read_json(&path);
2088 assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
2089 assert_eq!(val["extra"], true);
2090 assert_eq!(val["env"]["A_KEY"], "new");
2091 }
2092
2093 #[test]
2094 fn upsert_env_vars_replaces_non_object_env() {
2095 let (_tmp, path) = temp_settings_path();
2096 fs::create_dir_all(path.parent().unwrap()).unwrap();
2097 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2098
2099 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2100
2101 assert_eq!(read_json(&path)["env"]["A_KEY"], "a");
2102 }
2103
2104 #[cfg(unix)]
2105 #[test]
2106 fn upsert_env_vars_retightens_loose_permissions() {
2107 use std::os::unix::fs::PermissionsExt;
2108
2109 let (_tmp, path) = temp_settings_path();
2110 fs::create_dir_all(path.parent().unwrap()).unwrap();
2111 fs::write(&path, r#"{"env": {}}"#).unwrap();
2112 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
2113
2114 Settings::upsert_env_vars(&path, &[("A_KEY", "a")]).unwrap();
2115
2116 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2117 assert_eq!(file_mode & 0o777, 0o600);
2118 }
2119
2120 #[test]
2121 fn remove_env_vars_removes_listed_keys_and_preserves_rest() {
2122 let (_tmp, path) = temp_settings_path();
2123 fs::create_dir_all(path.parent().unwrap()).unwrap();
2124 fs::write(
2125 &path,
2126 r#"{"env": {"A_KEY": "a", "B_KEY": "b", "OTHER_KEY": "keep"}, "extra": true}"#,
2127 )
2128 .unwrap();
2129
2130 let removed = Settings::remove_env_vars(&path, &["A_KEY", "B_KEY", "ABSENT"]).unwrap();
2131 assert!(removed);
2132
2133 let val = read_json(&path);
2134 assert!(val["env"].get("A_KEY").is_none());
2135 assert!(val["env"].get("B_KEY").is_none());
2136 assert_eq!(val["env"]["OTHER_KEY"], "keep");
2137 assert_eq!(val["extra"], true);
2138 }
2139
2140 #[test]
2141 fn remove_env_vars_false_when_file_missing() {
2142 let (_tmp, path) = temp_settings_path();
2143 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2144 assert!(!path.exists());
2145 }
2146
2147 #[test]
2148 fn remove_env_vars_false_when_env_missing_or_not_an_object() {
2149 let (_tmp, path) = temp_settings_path();
2150 fs::create_dir_all(path.parent().unwrap()).unwrap();
2151
2152 fs::write(&path, r#"{"extra": true}"#).unwrap();
2154 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2155
2156 fs::write(&path, r#"{"env": "not-an-object"}"#).unwrap();
2158 assert!(!Settings::remove_env_vars(&path, &["A_KEY"]).unwrap());
2159 }
2160
2161 #[test]
2162 fn upsert_env_vars_bare_filename_skips_dir_creation() {
2163 let name = format!("tmp-upsert-bare-{}.json", std::process::id());
2166 let path = Path::new(&name);
2167
2168 Settings::upsert_env_vars(path, &[("A_KEY", "a")]).unwrap();
2169
2170 assert_eq!(read_json(path)["env"]["A_KEY"], "a");
2171 fs::remove_file(path).unwrap();
2172 }
2173
2174 #[test]
2175 fn remove_env_vars_false_when_keys_absent_leaves_file_untouched() {
2176 let (_tmp, path) = temp_settings_path();
2177 fs::create_dir_all(path.parent().unwrap()).unwrap();
2178 let original = r#"{"env": {"OTHER_KEY": "keep"}}"#;
2179 fs::write(&path, original).unwrap();
2180
2181 let removed = Settings::remove_env_vars(&path, &["A_KEY"]).unwrap();
2182 assert!(!removed);
2183 assert_eq!(fs::read_to_string(&path).unwrap(), original);
2185 }
2186
2187 #[test]
2190 fn upsert_env_vars_in_profile_creates_profile_env() {
2191 let (_tmp, path) = temp_settings_path();
2192
2193 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2194
2195 let val = read_json(&path);
2196 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2197 assert!(val.get("env").is_none());
2199
2200 #[cfg(unix)]
2203 {
2204 use std::os::unix::fs::PermissionsExt;
2205 let dir_mode = fs::metadata(path.parent().unwrap())
2206 .unwrap()
2207 .permissions()
2208 .mode();
2209 assert_eq!(dir_mode & 0o777, 0o700);
2210 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2211 assert_eq!(file_mode & 0o777, 0o600);
2212 }
2213 }
2214
2215 #[test]
2216 fn upsert_env_vars_in_profile_preserves_base_and_other_profiles() {
2217 let (_tmp, path) = temp_settings_path();
2218 fs::create_dir_all(path.parent().unwrap()).unwrap();
2219 fs::write(
2220 &path,
2221 r#"{
2222 "env": {"SHARED": "base"},
2223 "profiles": {
2224 "work": {"env": {"OLD": "keep"}},
2225 "home": {"env": {"SHARED": "home"}}
2226 },
2227 "extra": true
2228 }"#,
2229 )
2230 .unwrap();
2231
2232 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2233
2234 let val = read_json(&path);
2235 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "a");
2236 assert_eq!(val["profiles"]["work"]["env"]["OLD"], "keep");
2237 assert_eq!(val["profiles"]["home"]["env"]["SHARED"], "home");
2238 assert_eq!(val["env"]["SHARED"], "base");
2239 assert_eq!(val["extra"], true);
2240 }
2241
2242 #[test]
2243 fn upsert_env_vars_in_profile_replaces_non_object_nodes() {
2244 let (_tmp, path) = temp_settings_path();
2245 fs::create_dir_all(path.parent().unwrap()).unwrap();
2246
2247 fs::write(&path, r#"{"profiles": "bogus"}"#).unwrap();
2249 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2250 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2251
2252 fs::write(&path, r#"{"profiles": {"work": []}}"#).unwrap();
2254 Settings::upsert_env_vars_in(&path, Some("work"), &[("A_KEY", "a")]).unwrap();
2255 assert_eq!(read_json(&path)["profiles"]["work"]["env"]["A_KEY"], "a");
2256 }
2257
2258 #[test]
2259 fn remove_env_vars_in_profile_removes_only_profile_keys() {
2260 let (_tmp, path) = temp_settings_path();
2261 fs::create_dir_all(path.parent().unwrap()).unwrap();
2262 fs::write(
2263 &path,
2264 r#"{
2265 "env": {"A_KEY": "base"},
2266 "profiles": {"work": {"env": {"A_KEY": "work", "OTHER": "keep"}}}
2267 }"#,
2268 )
2269 .unwrap();
2270
2271 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2272 assert!(removed);
2273
2274 let val = read_json(&path);
2275 assert!(val["profiles"]["work"]["env"].get("A_KEY").is_none());
2276 assert_eq!(val["profiles"]["work"]["env"]["OTHER"], "keep");
2277 assert_eq!(val["env"]["A_KEY"], "base");
2279 }
2280
2281 #[test]
2282 fn remove_env_vars_in_profile_false_when_profile_missing() {
2283 let (_tmp, path) = temp_settings_path();
2284 fs::create_dir_all(path.parent().unwrap()).unwrap();
2285 let original = r#"{"env": {"A_KEY": "base"}}"#;
2286 fs::write(&path, original).unwrap();
2287
2288 let removed = Settings::remove_env_vars_in(&path, Some("work"), &["A_KEY"]).unwrap();
2289 assert!(!removed);
2290 assert_eq!(fs::read_to_string(&path).unwrap(), original);
2292 }
2293
2294 #[test]
2295 fn remove_env_vars_in_none_targets_base_env() {
2296 let (_tmp, path) = temp_settings_path();
2297 fs::create_dir_all(path.parent().unwrap()).unwrap();
2298 fs::write(
2299 &path,
2300 r#"{"env": {"A_KEY": "base"}, "profiles": {"work": {"env": {"A_KEY": "work"}}}}"#,
2301 )
2302 .unwrap();
2303
2304 let removed = Settings::remove_env_vars_in(&path, None, &["A_KEY"]).unwrap();
2305 assert!(removed);
2306
2307 let val = read_json(&path);
2308 assert!(val["env"].get("A_KEY").is_none());
2309 assert_eq!(val["profiles"]["work"]["env"]["A_KEY"], "work");
2310 }
2311
2312 #[test]
2315 fn ensure_object_at_creates_nested_path_at_arbitrary_depth() {
2316 let mut root = serde_json::json!({});
2317 {
2318 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2319 map.insert("client_id".to_string(), serde_json::json!("id"));
2320 }
2321 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2322 }
2323
2324 #[test]
2325 fn ensure_object_at_replaces_non_object_nodes_along_path() {
2326 let mut root = serde_json::json!({"gmail": "bogus"});
2327 {
2328 let map = ensure_object_at(&mut root, &["gmail", "accounts", "work"]).unwrap();
2329 map.insert("client_id".to_string(), serde_json::json!("id"));
2330 }
2331 assert_eq!(root["gmail"]["accounts"]["work"]["client_id"], "id");
2332 }
2333
2334 #[test]
2335 fn object_at_mut_none_when_any_segment_absent() {
2336 let mut root = serde_json::json!({"gmail": {"accounts": {}}});
2337 assert!(object_at_mut(&mut root, &["gmail", "accounts", "work"]).is_none());
2338 assert!(object_at_mut(&mut root, &["missing", "accounts"]).is_none());
2339 }
2340
2341 #[test]
2344 fn upsert_gmail_account_creates_nested_path_and_preserves_siblings() {
2345 let (_tmp, path) = temp_settings_path();
2346 fs::create_dir_all(path.parent().unwrap()).unwrap();
2347 fs::write(
2348 &path,
2349 r#"{"env": {"SHARED": "base"}, "gmail": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2350 )
2351 .unwrap();
2352
2353 Settings::upsert_gmail_account(
2354 &path,
2355 "work",
2356 &[
2357 ("client_id", serde_json::Value::String("id".to_string())),
2358 (
2359 "refresh_token",
2360 serde_json::Value::String("token".to_string()),
2361 ),
2362 ],
2363 )
2364 .unwrap();
2365
2366 let val = read_json(&path);
2367 assert_eq!(val["gmail"]["accounts"]["work"]["client_id"], "id");
2368 assert_eq!(val["gmail"]["accounts"]["work"]["refresh_token"], "token");
2369 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2370 assert_eq!(val["env"]["SHARED"], "base");
2371 assert_eq!(val["extra"], true);
2372
2373 #[cfg(unix)]
2374 {
2375 use std::os::unix::fs::PermissionsExt;
2376 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2377 assert_eq!(file_mode & 0o777, 0o600);
2378 }
2379 }
2380
2381 #[test]
2388 fn upsert_gmail_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2389 let (_tmp, path) = temp_settings_path();
2390
2391 Settings::upsert_gmail_account(
2392 &path,
2393 "work",
2394 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2395 )
2396 .unwrap();
2397
2398 let val = read_json(&path);
2399 assert_eq!(
2400 val["gmail"]["accounts"]["work"]["chrome_profile_from_email"],
2401 true
2402 );
2403
2404 let settings = Settings::load_from_path(&path).unwrap();
2405 assert!(
2406 settings.gmail.accounts["work"].chrome_profile_from_email,
2407 "the bool field must deserialize back to `true`, not the string \"true\""
2408 );
2409 }
2410
2411 #[test]
2412 fn remove_gmail_account_true_when_present_false_when_absent() {
2413 let (_tmp, path) = temp_settings_path();
2414 fs::create_dir_all(path.parent().unwrap()).unwrap();
2415 fs::write(
2416 &path,
2417 r#"{"gmail": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2418 )
2419 .unwrap();
2420
2421 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2422 let val = read_json(&path);
2423 assert!(val["gmail"]["accounts"].get("work").is_none());
2424 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2425
2426 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2427 }
2428
2429 #[test]
2430 fn remove_gmail_account_clears_default_account_when_it_named_the_removed_account() {
2431 let (_tmp, path) = temp_settings_path();
2432 fs::create_dir_all(path.parent().unwrap()).unwrap();
2433 fs::write(
2434 &path,
2435 r#"{"gmail": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2436 )
2437 .unwrap();
2438
2439 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2440 let val = read_json(&path);
2441 assert!(val["gmail"].get("default_account").is_none());
2442 assert_eq!(val["gmail"]["accounts"]["personal"]["client_id"], "keep");
2443 }
2444
2445 #[test]
2446 fn remove_gmail_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2447 let (_tmp, path) = temp_settings_path();
2448 fs::create_dir_all(path.parent().unwrap()).unwrap();
2449 fs::write(
2450 &path,
2451 r#"{"gmail": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2452 )
2453 .unwrap();
2454
2455 assert!(Settings::remove_gmail_account(&path, "work").unwrap());
2456 let val = read_json(&path);
2457 assert_eq!(val["gmail"]["default_account"], "personal");
2458 }
2459
2460 #[test]
2461 fn remove_gmail_account_false_when_file_missing() {
2462 let (_tmp, path) = temp_settings_path();
2463 assert!(!Settings::remove_gmail_account(&path, "work").unwrap());
2464 assert!(!path.exists());
2465 }
2466
2467 #[test]
2468 fn set_gmail_default_account_sets_and_clears() {
2469 let (_tmp, path) = temp_settings_path();
2470
2471 Settings::set_gmail_default_account(&path, Some("work")).unwrap();
2472 assert_eq!(read_json(&path)["gmail"]["default_account"], "work");
2473
2474 Settings::set_gmail_default_account(&path, None).unwrap();
2475 assert!(read_json(&path)["gmail"].get("default_account").is_none());
2476 }
2477
2478 #[test]
2481 fn upsert_drive_account_creates_nested_path_and_preserves_siblings() {
2482 let (_tmp, path) = temp_settings_path();
2483 fs::create_dir_all(path.parent().unwrap()).unwrap();
2484 fs::write(
2485 &path,
2486 r#"{"env": {"SHARED": "base"}, "drive": {"accounts": {"personal": {"client_id": "keep"}}}, "extra": true}"#,
2487 )
2488 .unwrap();
2489
2490 Settings::upsert_drive_account(
2491 &path,
2492 "work",
2493 &[
2494 ("client_id", serde_json::Value::String("id".to_string())),
2495 (
2496 "refresh_token",
2497 serde_json::Value::String("token".to_string()),
2498 ),
2499 ],
2500 )
2501 .unwrap();
2502
2503 let val = read_json(&path);
2504 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2505 assert_eq!(val["drive"]["accounts"]["work"]["refresh_token"], "token");
2506 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2507 assert_eq!(val["env"]["SHARED"], "base");
2508 assert_eq!(val["extra"], true);
2509
2510 #[cfg(unix)]
2511 {
2512 use std::os::unix::fs::PermissionsExt;
2513 let file_mode = fs::metadata(&path).unwrap().permissions().mode();
2514 assert_eq!(file_mode & 0o777, 0o600);
2515 }
2516 }
2517
2518 fn settings_with_secret_files(
2523 path: &Path,
2524 client_secret_file: Option<&Path>,
2525 refresh_token_file: &Path,
2526 ) {
2527 let mut work = serde_json::json!({
2528 "client_id": "id",
2529 "refresh_token_file": refresh_token_file.to_str().unwrap(),
2530 });
2531 if let Some(file) = client_secret_file {
2532 work["client_secret_file"] = serde_json::json!(file.to_str().unwrap());
2533 }
2534 fs::create_dir_all(path.parent().unwrap()).unwrap();
2535 fs::write(
2536 path,
2537 serde_json::json!({ "drive": { "accounts": { "work": work } } }).to_string(),
2538 )
2539 .unwrap();
2540 }
2541
2542 #[test]
2543 fn account_secret_file_fields_deserialize() {
2544 let (_tmp, path) = temp_settings_path();
2545 settings_with_secret_files(&path, Some(Path::new("/s/cs")), Path::new("/s/rt"));
2546 let settings = Settings::load_from_path(&path).unwrap();
2547 let work = &settings.drive.accounts["work"];
2548 assert_eq!(work.client_secret_file.as_deref(), Some("/s/cs"));
2549 assert_eq!(work.refresh_token_file.as_deref(), Some("/s/rt"));
2550 assert!(work.client_secret.is_none());
2551 }
2552
2553 #[test]
2554 fn upsert_account_writes_a_secret_into_its_file_not_settings() {
2555 let (tmp, path) = temp_settings_path();
2556 let token_file = tmp.path().join("refresh-token");
2557 settings_with_secret_files(&path, None, &token_file);
2558
2559 Settings::upsert_drive_account(
2560 &path,
2561 "work",
2562 &[
2563 ("client_secret", serde_json::json!("plain-secret")),
2564 ("refresh_token", serde_json::json!("new-token")),
2565 ("scope", serde_json::json!("s")),
2566 ],
2567 )
2568 .unwrap();
2569
2570 let raw = fs::read_to_string(&path).unwrap();
2571 assert!(!raw.contains("new-token"), "{raw}");
2572 let val = read_json(&path);
2573 let work = &val["drive"]["accounts"]["work"];
2574 assert!(work.get("refresh_token").is_none());
2575 assert_eq!(work["refresh_token_file"], token_file.to_str().unwrap());
2576 assert_eq!(work["client_secret"], "plain-secret");
2578 assert_eq!(work["scope"], "s");
2579 assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2580 }
2581
2582 #[test]
2583 fn upsert_account_drops_a_plain_field_that_would_conflict_with_its_file() {
2584 let (tmp, path) = temp_settings_path();
2585 let secret_file = tmp.path().join("client-secret");
2586 fs::create_dir_all(path.parent().unwrap()).unwrap();
2587 fs::write(
2588 &path,
2589 serde_json::json!({ "gmail": { "accounts": { "work": {
2590 "client_secret": "stale",
2591 "client_secret_file": secret_file.to_str().unwrap(),
2592 }}}})
2593 .to_string(),
2594 )
2595 .unwrap();
2596
2597 Settings::upsert_gmail_account(
2598 &path,
2599 "work",
2600 &[("client_secret", serde_json::json!("fresh"))],
2601 )
2602 .unwrap();
2603
2604 let val = read_json(&path);
2605 assert!(val["gmail"]["accounts"]["work"]
2606 .get("client_secret")
2607 .is_none());
2608 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "fresh\n");
2609 }
2610
2611 #[test]
2612 fn upsert_account_leaves_settings_untouched_when_the_secret_write_fails() {
2613 let (tmp, path) = temp_settings_path();
2614 let token_file = tmp.path().join("missing-dir").join("refresh-token");
2615 settings_with_secret_files(&path, None, &token_file);
2616 let before = fs::read_to_string(&path).unwrap();
2617
2618 let err = Settings::upsert_drive_account(
2619 &path,
2620 "work",
2621 &[
2622 ("scope", serde_json::json!("s")),
2623 ("refresh_token", serde_json::json!("new-token")),
2624 ],
2625 )
2626 .unwrap_err();
2627
2628 assert!(
2629 err.to_string()
2630 .contains("drive.accounts.work.refresh_token_file"),
2631 "{err}"
2632 );
2633 assert!(!err.to_string().contains("new-token"), "{err}");
2634 assert_eq!(fs::read_to_string(&path).unwrap(), before);
2635 }
2636
2637 fn owner_only(dir: &Path, name: &str, contents: &str) -> std::path::PathBuf {
2639 let file = dir.join(name);
2640 fs::write(&file, contents).unwrap();
2641 #[cfg(unix)]
2642 {
2643 use std::os::unix::fs::PermissionsExt;
2644 fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap();
2645 }
2646 file
2647 }
2648
2649 #[test]
2650 fn upsert_account_checks_every_secret_file_before_writing_any() {
2651 let (tmp, path) = temp_settings_path();
2652 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2653 let token_file = tmp.path().join("missing-dir").join("refresh-token");
2654 settings_with_secret_files(&path, Some(&secret_file), &token_file);
2655
2656 Settings::upsert_drive_account(
2657 &path,
2658 "work",
2659 &[
2660 ("client_secret", serde_json::json!("new-secret")),
2661 ("refresh_token", serde_json::json!("new-token")),
2662 ],
2663 )
2664 .unwrap_err();
2665
2666 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2667 }
2668
2669 #[test]
2670 fn upsert_account_refuses_to_replace_a_client_secret_file_of_another_client() {
2671 let (tmp, path) = temp_settings_path();
2672 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2673 let token_file = tmp.path().join("refresh-token");
2674 settings_with_secret_files(&path, Some(&secret_file), &token_file);
2675 let before = fs::read_to_string(&path).unwrap();
2676
2677 let err = Settings::upsert_drive_account(
2678 &path,
2679 "work",
2680 &[
2681 ("client_id", serde_json::json!("other-id")),
2682 ("client_secret", serde_json::json!("other-secret")),
2683 ("refresh_token", serde_json::json!("new-token")),
2684 ],
2685 )
2686 .unwrap_err();
2687
2688 let message = err.to_string();
2689 assert!(
2690 message.contains("drive.accounts.work.client_secret_file"),
2691 "{message}"
2692 );
2693 assert!(message.contains("other-id"), "{message}");
2694 assert!(!message.contains("other-secret"), "{message}");
2695 assert_eq!(fs::read_to_string(&secret_file).unwrap(), "old-secret\n");
2696 assert!(!token_file.exists());
2697 assert_eq!(fs::read_to_string(&path).unwrap(), before);
2698 }
2699
2700 #[test]
2701 fn upsert_account_replaces_a_client_secret_file_for_the_same_client() {
2702 let (tmp, path) = temp_settings_path();
2703 let secret_file = owner_only(tmp.path(), "client-secret", "old-secret\n");
2704 let token_file = tmp.path().join("refresh-token");
2705 settings_with_secret_files(&path, Some(&secret_file), &token_file);
2706
2707 Settings::upsert_drive_account(
2708 &path,
2709 "work",
2710 &[
2711 ("client_id", serde_json::json!("id")),
2712 ("client_secret", serde_json::json!("rotated-secret")),
2713 ("refresh_token", serde_json::json!("new-token")),
2714 ],
2715 )
2716 .unwrap();
2717
2718 assert_eq!(
2719 fs::read_to_string(&secret_file).unwrap(),
2720 "rotated-secret\n"
2721 );
2722 assert_eq!(fs::read_to_string(&token_file).unwrap(), "new-token\n");
2723 }
2724
2725 #[test]
2726 fn upsert_gmail_account_writes_a_refresh_token_into_its_file() {
2727 let (tmp, path) = temp_settings_path();
2728 let token_file = tmp.path().join("gmail-refresh-token");
2729 fs::create_dir_all(path.parent().unwrap()).unwrap();
2730 fs::write(
2731 &path,
2732 serde_json::json!({ "gmail": { "accounts": { "work": {
2733 "refresh_token_file": token_file.to_str().unwrap(),
2734 }}}})
2735 .to_string(),
2736 )
2737 .unwrap();
2738
2739 Settings::upsert_gmail_account(
2740 &path,
2741 "work",
2742 &[("refresh_token", serde_json::json!("gmail-token"))],
2743 )
2744 .unwrap();
2745
2746 assert!(!fs::read_to_string(&path).unwrap().contains("gmail-token"));
2747 assert_eq!(fs::read_to_string(&token_file).unwrap(), "gmail-token\n");
2748 let settings = Settings::load_from_path(&path).unwrap();
2749 assert!(settings.gmail.accounts["work"].refresh_token.is_none());
2750 }
2751
2752 #[test]
2757 fn upsert_drive_account_writes_a_bool_value_that_round_trips_through_settings_load() {
2758 let (_tmp, path) = temp_settings_path();
2759
2760 Settings::upsert_drive_account(
2761 &path,
2762 "work",
2763 &[("chrome_profile_from_email", serde_json::Value::Bool(true))],
2764 )
2765 .unwrap();
2766
2767 let val = read_json(&path);
2768 assert_eq!(
2769 val["drive"]["accounts"]["work"]["chrome_profile_from_email"],
2770 true
2771 );
2772
2773 let settings = Settings::load_from_path(&path).unwrap();
2774 assert!(
2775 settings.drive.accounts["work"].chrome_profile_from_email,
2776 "the bool field must deserialize back to `true`, not the string \"true\""
2777 );
2778 }
2779
2780 #[test]
2783 fn write_permissions_field_round_trips_through_settings_load() {
2784 let (_tmp, path) = temp_settings_path();
2785 fs::create_dir_all(path.parent().unwrap()).unwrap();
2786 fs::write(
2787 &path,
2788 r#"{
2789 "drive": {
2790 "accounts": {
2791 "work": {
2792 "write_permissions": {
2793 "rules": [
2794 {
2795 "folder_id": "folder-1",
2796 "recursive": true,
2797 "allow": ["create", "upload"],
2798 "deny": ["edit"]
2799 }
2800 ]
2801 }
2802 }
2803 }
2804 }
2805 }"#,
2806 )
2807 .unwrap();
2808
2809 let settings = Settings::load_from_path(&path).unwrap();
2810 let rules = &settings.drive.accounts["work"].write_permissions.rules;
2811 assert_eq!(rules.len(), 1);
2812 assert_eq!(rules[0].folder_id.as_deref(), Some("folder-1"));
2813 assert!(rules[0].recursive);
2814 assert!(rules[0]
2815 .allow
2816 .contains(&crate::drive::write_gate::DriveOperation::Create));
2817 assert!(rules[0]
2818 .allow
2819 .contains(&crate::drive::write_gate::DriveOperation::Upload));
2820 assert!(rules[0]
2821 .deny
2822 .contains(&crate::drive::write_gate::DriveOperation::Edit));
2823 }
2824
2825 #[test]
2826 fn write_permissions_absent_defaults_to_empty_rules() {
2827 let (_tmp, path) = temp_settings_path();
2828 fs::create_dir_all(path.parent().unwrap()).unwrap();
2829 fs::write(
2830 &path,
2831 r#"{"drive": {"accounts": {"work": {"client_id": "id"}}}}"#,
2832 )
2833 .unwrap();
2834
2835 let settings = Settings::load_from_path(&path).unwrap();
2836 assert!(settings.drive.accounts["work"]
2837 .write_permissions
2838 .rules
2839 .is_empty());
2840 }
2841
2842 #[test]
2850 fn write_permissions_unknown_future_field_is_preserved_through_read_modify_write() {
2851 let (_tmp, path) = temp_settings_path();
2852 fs::create_dir_all(path.parent().unwrap()).unwrap();
2853 fs::write(
2854 &path,
2855 r#"{
2856 "drive": {
2857 "accounts": {
2858 "work": {
2859 "write_permissions": {
2860 "rules": [],
2861 "future_field": "not yet modeled"
2862 }
2863 }
2864 }
2865 }
2866 }"#,
2867 )
2868 .unwrap();
2869
2870 Settings::upsert_drive_account(
2871 &path,
2872 "work",
2873 &[("client_id", serde_json::Value::String("id".to_string()))],
2874 )
2875 .unwrap();
2876
2877 let val = read_json(&path);
2878 assert_eq!(
2879 val["drive"]["accounts"]["work"]["write_permissions"]["future_field"],
2880 "not yet modeled"
2881 );
2882 assert_eq!(val["drive"]["accounts"]["work"]["client_id"], "id");
2883 }
2884
2885 #[test]
2886 fn remove_drive_account_true_when_present_false_when_absent() {
2887 let (_tmp, path) = temp_settings_path();
2888 fs::create_dir_all(path.parent().unwrap()).unwrap();
2889 fs::write(
2890 &path,
2891 r#"{"drive": {"accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2892 )
2893 .unwrap();
2894
2895 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2896 let val = read_json(&path);
2897 assert!(val["drive"]["accounts"].get("work").is_none());
2898 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2899
2900 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2901 }
2902
2903 #[test]
2904 fn remove_drive_account_clears_default_account_when_it_named_the_removed_account() {
2905 let (_tmp, path) = temp_settings_path();
2906 fs::create_dir_all(path.parent().unwrap()).unwrap();
2907 fs::write(
2908 &path,
2909 r#"{"drive": {"default_account": "work", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2910 )
2911 .unwrap();
2912
2913 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2914 let val = read_json(&path);
2915 assert!(val["drive"].get("default_account").is_none());
2916 assert_eq!(val["drive"]["accounts"]["personal"]["client_id"], "keep");
2917 }
2918
2919 #[test]
2920 fn remove_drive_account_leaves_default_account_untouched_when_it_names_a_different_account() {
2921 let (_tmp, path) = temp_settings_path();
2922 fs::create_dir_all(path.parent().unwrap()).unwrap();
2923 fs::write(
2924 &path,
2925 r#"{"drive": {"default_account": "personal", "accounts": {"work": {"client_id": "id"}, "personal": {"client_id": "keep"}}}}"#,
2926 )
2927 .unwrap();
2928
2929 assert!(Settings::remove_drive_account(&path, "work").unwrap());
2930 let val = read_json(&path);
2931 assert_eq!(val["drive"]["default_account"], "personal");
2932 }
2933
2934 #[test]
2935 fn remove_drive_account_false_when_file_missing() {
2936 let (_tmp, path) = temp_settings_path();
2937 assert!(!Settings::remove_drive_account(&path, "work").unwrap());
2938 assert!(!path.exists());
2939 }
2940
2941 #[test]
2942 fn set_drive_default_account_sets_and_clears() {
2943 let (_tmp, path) = temp_settings_path();
2944
2945 Settings::set_drive_default_account(&path, Some("work")).unwrap();
2946 assert_eq!(read_json(&path)["drive"]["default_account"], "work");
2947
2948 Settings::set_drive_default_account(&path, None).unwrap();
2949 assert!(read_json(&path)["drive"].get("default_account").is_none());
2950 }
2951
2952 fn load_with_rules(rules_json: &str) -> Result<Settings> {
2956 let (_tmp, path) = temp_settings_path();
2957 fs::create_dir_all(path.parent().unwrap()).unwrap();
2958 fs::write(
2959 &path,
2960 format!(
2961 r#"{{"drive":{{"accounts":{{"work":{{"write_permissions":{{"rules":{rules_json}}}}}}}}}}}"#
2962 ),
2963 )
2964 .unwrap();
2965 Settings::load_from_path(&path)
2966 }
2967
2968 #[test]
2969 fn a_file_id_rule_round_trips_through_settings_load() {
2970 let settings =
2971 load_with_rules(r#"[{"file_id":"sheet-1","allow":["sheets-write"]}]"#).unwrap();
2972 let rules = &settings.drive.accounts["work"].write_permissions.rules;
2973 assert_eq!(rules.len(), 1);
2974 assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
2975 assert_eq!(rules[0].folder_id, None);
2976 assert!(!rules[0].recursive);
2977 assert!(rules[0]
2978 .allow
2979 .contains(&crate::drive::write_gate::DriveOperation::SheetsWrite));
2980 }
2981
2982 #[test]
2983 fn folder_and_file_rules_coexist_in_one_list() {
2984 let settings = load_with_rules(
2985 r#"[{"folder_id":"f1","recursive":true,"allow":["create"]},
2986 {"file_id":"x1","deny":["edit"]}]"#,
2987 )
2988 .unwrap();
2989 let rules = &settings.drive.accounts["work"].write_permissions.rules;
2990 assert_eq!(rules.len(), 2);
2991 assert_eq!(rules[0].folder_id.as_deref(), Some("f1"));
2992 assert_eq!(rules[1].file_id.as_deref(), Some("x1"));
2993 }
2994
2995 #[test]
2996 fn a_rule_naming_no_target_fails_the_whole_load() {
2997 assert!(load_with_rules(r#"[{"allow":["create"]}]"#).is_err());
3000 }
3001
3002 #[test]
3003 fn a_rule_naming_both_targets_fails_the_whole_load() {
3004 assert!(load_with_rules(r#"[{"folder_id":"f1","file_id":"x1"}]"#).is_err());
3005 }
3006
3007 #[test]
3008 fn recursive_true_on_a_file_rule_fails_the_whole_load() {
3009 assert!(load_with_rules(r#"[{"file_id":"x1","recursive":true}]"#).is_err());
3010 }
3011
3012 #[test]
3013 fn an_older_binary_would_fail_closed_on_a_file_rule() {
3014 #[derive(Debug, serde::Deserialize)]
3022 #[allow(dead_code)]
3023 struct OldFolderPermissionRule {
3024 folder_id: String,
3025 }
3026 let err = serde_json::from_str::<OldFolderPermissionRule>(r#"{"file_id":"x1"}"#)
3027 .unwrap_err()
3028 .to_string();
3029 assert!(err.contains("folder_id"), "{err}");
3030 }
3031
3032 #[test]
3033 fn upsert_drive_account_preserves_file_id_rules_it_cannot_understand() {
3034 let (_tmp, path) = temp_settings_path();
3038 fs::create_dir_all(path.parent().unwrap()).unwrap();
3039 fs::write(
3040 &path,
3041 r#"{"drive":{"accounts":{"work":{"write_permissions":{"rules":[
3042 {"file_id":"sheet-1","allow":["sheets-write"]}]}}}}}"#,
3043 )
3044 .unwrap();
3045
3046 Settings::upsert_drive_account(
3047 &path,
3048 "work",
3049 &[("email", serde_json::Value::String("a@b.c".to_string()))],
3050 )
3051 .unwrap();
3052
3053 let reloaded = Settings::load_from_path(&path).unwrap();
3054 let rules = &reloaded.drive.accounts["work"].write_permissions.rules;
3055 assert_eq!(rules.len(), 1);
3056 assert_eq!(rules[0].file_id.as_deref(), Some("sheet-1"));
3057 }
3058}