1use std::cell::Cell;
38use std::path::{Path, PathBuf};
39use std::str::FromStr;
40
41use serde::de::DeserializeOwned;
42use serde::{Deserialize, Deserializer};
43use toml_edit::DocumentMut;
44
45use crate::error::CliCoreError;
46
47#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
54#[non_exhaustive]
55pub enum CredentialStore {
56 #[default]
59 Auto,
60 Keyring,
63 File,
66}
67
68impl CredentialStore {
69 #[must_use]
71 pub fn as_str(self) -> &'static str {
72 match self {
73 CredentialStore::Auto => "auto",
74 CredentialStore::Keyring => "keyring",
75 CredentialStore::File => "file",
76 }
77 }
78}
79
80impl std::fmt::Display for CredentialStore {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.write_str(self.as_str())
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct ParseCredentialStoreError(String);
89
90impl std::fmt::Display for ParseCredentialStoreError {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 write!(
93 f,
94 "invalid credential store {:?} (expected one of: auto, keyring, file)",
95 self.0
96 )
97 }
98}
99
100impl std::error::Error for ParseCredentialStoreError {}
101
102impl FromStr for CredentialStore {
103 type Err = ParseCredentialStoreError;
104
105 fn from_str(s: &str) -> Result<Self, Self::Err> {
106 match s.trim().to_ascii_lowercase().as_str() {
107 "auto" => Ok(CredentialStore::Auto),
108 "keyring" | "keychain" => Ok(CredentialStore::Keyring),
110 "file" => Ok(CredentialStore::File),
111 _ => Err(ParseCredentialStoreError(s.to_owned())),
112 }
113 }
114}
115
116impl<'de> Deserialize<'de> for CredentialStore {
117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118 where
119 D: Deserializer<'de>,
120 {
121 let raw = String::deserialize(deserializer)?;
122 raw.parse().map_err(serde::de::Error::custom)
123 }
124}
125
126#[derive(Clone, Debug, Default, Deserialize)]
131#[serde(default)]
132pub struct EngineConfig {
133 pub credentials: CredentialsConfig,
135 pub output: OutputConfig,
137}
138
139#[derive(Clone, Debug, Default, Deserialize)]
141#[serde(default)]
142pub struct CredentialsConfig {
143 pub store: Option<CredentialStore>,
145}
146
147#[derive(Clone, Debug, Default, Deserialize)]
149#[serde(default)]
150pub struct OutputConfig {
151 pub format: Option<String>,
160}
161
162thread_local! {
174 static CREDENTIAL_STORE_FLAG: Cell<u8> = const { Cell::new(0) };
175}
176
177fn encode_store(store: Option<CredentialStore>) -> u8 {
178 match store {
179 None => 0,
180 Some(CredentialStore::Auto) => 1,
181 Some(CredentialStore::Keyring) => 2,
182 Some(CredentialStore::File) => 3,
183 }
184}
185
186fn decode_store(byte: u8) -> Option<CredentialStore> {
187 match byte {
188 1 => Some(CredentialStore::Auto),
189 2 => Some(CredentialStore::Keyring),
190 3 => Some(CredentialStore::File),
191 _ => None,
192 }
193}
194
195pub(crate) fn set_credential_store_flag(store: Option<CredentialStore>) {
201 CREDENTIAL_STORE_FLAG.with(|f| f.set(encode_store(store)));
202}
203
204pub(crate) fn clear_credential_store_flag() {
209 CREDENTIAL_STORE_FLAG.with(|f| f.set(0));
210}
211
212#[must_use]
215pub(crate) fn credential_store_flag() -> Option<CredentialStore> {
216 CREDENTIAL_STORE_FLAG.with(|f| decode_store(f.get()))
217}
218
219#[must_use]
222pub fn credential_store_env_var(app_id: &str) -> String {
223 format!(
224 "{}_CREDENTIAL_STORE",
225 crate::flags::app_id_env_prefix(app_id)
226 )
227}
228
229#[must_use]
232pub fn config_file_path(app_id: &str) -> Option<PathBuf> {
233 if !crate::fs::is_safe_path_component(app_id) {
234 tracing::warn!(app_id, "refusing config path with unsafe app id");
235 return None;
236 }
237 crate::fs::config_base_dir().map(|base| base.join(app_id).join("config.toml"))
238}
239
240#[must_use]
246pub fn load(app_id: &str) -> EngineConfig {
247 ConfigFile::load(app_id).engine()
248}
249
250#[derive(Clone, Debug)]
269pub struct ConfigFile {
270 path: Option<PathBuf>,
271 doc: DocumentMut,
272}
273
274impl Default for ConfigFile {
275 fn default() -> Self {
276 Self::from_doc(None, DocumentMut::new())
277 }
278}
279
280impl ConfigFile {
281 fn from_doc(path: Option<PathBuf>, doc: DocumentMut) -> Self {
282 Self { path, doc }
283 }
284
285 #[must_use]
297 pub fn load(app_id: &str) -> Self {
298 let path = config_file_path(app_id);
299 let doc = match &path {
300 None => DocumentMut::new(),
301 Some(p) => match std::fs::read_to_string(p) {
302 Ok(contents) => contents.parse::<DocumentMut>().unwrap_or_else(|e| {
303 tracing::warn!(path = %p.display(), error = %e, "ignoring malformed config file");
304 DocumentMut::new()
305 }),
306 Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
307 Err(e) => {
308 tracing::warn!(path = %p.display(), error = %e, "could not read config file");
309 DocumentMut::new()
310 }
311 },
312 };
313 Self::from_doc(path, doc)
314 }
315
316 #[must_use]
320 pub fn path(&self) -> Option<&Path> {
321 self.path.as_deref()
322 }
323
324 #[must_use]
329 pub fn engine(&self) -> EngineConfig {
330 self.deserialize().unwrap_or_default()
331 }
332
333 pub fn section<T: DeserializeOwned>(&self, name: &str) -> crate::Result<Option<T>> {
343 let item = match self.doc.get(name) {
344 None => return Ok(None),
345 Some(item) => item,
346 };
347 let mut tmp = DocumentMut::new();
350 if let Some(tbl) = item.as_table_like() {
351 for (k, v) in tbl.iter() {
352 tmp[k] = v.clone();
353 }
354 }
355 toml_edit::de::from_document::<T>(tmp)
356 .map(Some)
357 .map_err(|e| CliCoreError::message(format!("config section {name:?}: {e}")))
358 }
359
360 pub fn deserialize<T: DeserializeOwned>(&self) -> crate::Result<T> {
368 toml_edit::de::from_document::<T>(self.doc.clone())
369 .map_err(|e| CliCoreError::message(format!("config deserialize error: {e}")))
370 }
371
372 #[must_use]
377 pub fn get(&self, dotted_key: &str) -> Option<String> {
378 let mut item = self.doc.as_item();
379 for segment in dotted_key.split('.') {
380 item = item.as_table_like()?.get(segment)?;
381 }
382 match item.as_value() {
383 Some(toml_edit::Value::String(s)) => Some(s.value().clone()),
384 Some(other) => Some(other.to_string().trim().to_owned()),
385 None => Some(item.to_string()),
386 }
387 }
388
389 pub fn set(&mut self, dotted_key: &str, value: &str) -> crate::Result<()> {
413 const ENGINE_RESERVED_TABLES: &[&str] = &["credentials", "output"];
418 let first_segment = dotted_key.split('.').next().unwrap_or("");
419 if ENGINE_RESERVED_TABLES.contains(&first_segment) {
420 match dotted_key {
421 "credentials.store" => {
422 value
423 .parse::<CredentialStore>()
424 .map_err(|e| CliCoreError::message(e.to_string()))?;
425 }
426 "output.format" => {
427 if !crate::output::is_valid_output_format(&value.trim().to_ascii_lowercase()) {
428 return Err(CliCoreError::message(format!(
429 "invalid output format {value:?} (expected one of: json, human, toon)"
430 )));
431 }
432 }
433 other => {
434 return Err(CliCoreError::message(format!(
435 "unknown engine-reserved key {other:?}; the only supported keys are \
436 \"credentials.store\" and \"output.format\""
437 )));
438 }
439 }
440 }
441 let segments: Vec<&str> = dotted_key.split('.').collect();
442 if segments.iter().any(|s| s.is_empty()) {
443 return Err(CliCoreError::message(format!(
444 "invalid config key {dotted_key:?}"
445 )));
446 }
447 let Some((last, parents)) = segments.split_last() else {
448 return Err(CliCoreError::message("empty config key"));
449 };
450 let mut table = self.doc.as_table_mut();
451 for segment in parents {
452 let entry = table
453 .entry(segment)
454 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
455 table = entry.as_table_mut().ok_or_else(|| {
456 CliCoreError::message(format!("config key {segment:?} is not a table"))
457 })?;
458 }
459 table[last] = toml_edit::Item::Value(infer_toml_value(value));
460 Ok(())
461 }
462
463 #[must_use]
466 pub fn to_toml_string(&self) -> String {
467 self.doc.to_string()
468 }
469
470 pub fn save(&self) -> crate::Result<()> {
476 let path = self.path.as_ref().ok_or_else(|| {
477 CliCoreError::message(
478 "no config path available (set XDG_CONFIG_HOME, HOME, or %APPDATA% \
479 to a directory)",
480 )
481 })?;
482 crate::fs::write_string_atomic(path, &self.doc.to_string())
483 }
484}
485
486fn infer_toml_value(value: &str) -> toml_edit::Value {
488 if let Ok(b) = value.parse::<bool>() {
489 return b.into();
490 }
491 if let Ok(i) = value.parse::<i64>() {
492 return i.into();
493 }
494 if let Ok(f) = value.parse::<f64>() {
495 return f.into();
496 }
497 value.into()
498}
499
500#[must_use]
507pub fn resolve_credential_store_with(
508 flag: Option<CredentialStore>,
509 env: Option<&str>,
510 file: &EngineConfig,
511) -> CredentialStore {
512 if let Some(store) = flag {
513 return store;
514 }
515 if let Some(raw) = env {
516 match raw.parse::<CredentialStore>() {
517 Ok(store) => return store,
518 Err(e) => tracing::warn!(error = %e, "ignoring invalid credential-store env var"),
519 }
520 }
521 if let Some(store) = file.credentials.store {
522 return store;
523 }
524 CredentialStore::default()
525}
526
527pub fn resolve_credential_store(
535 app_id: &str,
536 var: impl Fn(&str) -> Option<String>,
537) -> CredentialStore {
538 let env = var(&credential_store_env_var(app_id));
539 let file = load(app_id);
540 resolve_credential_store_with(credential_store_flag(), env.as_deref(), &file)
541}
542
543#[cfg(test)]
550#[allow(unsafe_code, dead_code)]
551pub(crate) mod test_env {
552 use std::path::Path;
553 use std::sync::{Mutex, MutexGuard};
554
555 pub(crate) static XDG_TEST_MUTEX: Mutex<()> = Mutex::new(());
557
558 pub(crate) fn lock() -> MutexGuard<'static, ()> {
563 XDG_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
564 }
565
566 pub(crate) struct EnvVarGuard {
569 key: &'static str,
570 prev: Option<String>,
571 }
572
573 impl EnvVarGuard {
574 pub(crate) fn set(key: &'static str, value: Option<&Path>) -> Self {
577 let prev = std::env::var(key).ok();
578 unsafe {
580 match value {
581 Some(v) => std::env::set_var(key, v),
582 None => std::env::remove_var(key),
583 }
584 }
585 Self { key, prev }
586 }
587 }
588
589 impl Drop for EnvVarGuard {
590 fn drop(&mut self) {
591 unsafe {
593 match self.prev.take() {
594 Some(v) => std::env::set_var(self.key, v),
595 None => std::env::remove_var(self.key),
596 }
597 }
598 }
599 }
600
601 pub(crate) fn with_xdg_config_home<F: FnOnce() -> R, R>(value: &Path, f: F) -> R {
604 let _lock = lock();
605 let _restore = EnvVarGuard::set("XDG_CONFIG_HOME", Some(value));
606 f()
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613
614 #[test]
615 fn parses_known_variants_case_insensitively() {
616 assert_eq!("auto".parse(), Ok(CredentialStore::Auto));
617 assert_eq!("Keyring".parse(), Ok(CredentialStore::Keyring));
618 assert_eq!("KEYCHAIN".parse(), Ok(CredentialStore::Keyring));
619 assert_eq!(" file ".parse(), Ok(CredentialStore::File));
620 }
621
622 #[test]
623 fn rejects_unknown_variant() {
624 let err = "vault"
625 .parse::<CredentialStore>()
626 .expect_err("should reject");
627 assert!(err.to_string().contains("vault"));
628 }
629
630 #[test]
631 fn display_round_trips_through_from_str() {
632 for store in [
633 CredentialStore::Auto,
634 CredentialStore::Keyring,
635 CredentialStore::File,
636 ] {
637 assert_eq!(store.to_string().parse(), Ok(store));
638 }
639 }
640
641 #[test]
642 fn env_var_name_is_derived_from_app_id() {
643 assert_eq!(
644 credential_store_env_var("godaddy"),
645 "GODADDY_CREDENTIAL_STORE"
646 );
647 assert_eq!(
648 credential_store_env_var("my-cli"),
649 "MY_CLI_CREDENTIAL_STORE"
650 );
651 }
652
653 #[test]
654 fn deserializes_store_from_toml() {
655 let config: EngineConfig =
656 toml_edit::de::from_str("[credentials]\nstore = \"file\"\n").expect("valid toml");
657 assert_eq!(config.credentials.store, Some(CredentialStore::File));
658 }
659
660 #[test]
661 fn deserialize_rejects_bad_store_value() {
662 let result = toml_edit::de::from_str::<EngineConfig>("[credentials]\nstore = \"nope\"\n");
663 assert!(result.is_err(), "bad store value should fail to parse");
664 }
665
666 #[test]
667 fn unknown_keys_are_ignored() {
668 let config: EngineConfig =
669 toml_edit::de::from_str("future_section = true\n[credentials]\nstore = \"auto\"\n")
670 .expect("unknown keys tolerated");
671 assert_eq!(config.credentials.store, Some(CredentialStore::Auto));
672 }
673
674 #[test]
675 fn resolution_precedence_flag_beats_env_beats_file() {
676 let file = EngineConfig {
677 credentials: CredentialsConfig {
678 store: Some(CredentialStore::Keyring),
679 },
680 ..Default::default()
681 };
682 assert_eq!(
684 resolve_credential_store_with(Some(CredentialStore::Auto), Some("file"), &file),
685 CredentialStore::Auto
686 );
687 assert_eq!(
689 resolve_credential_store_with(None, Some("file"), &file),
690 CredentialStore::File
691 );
692 assert_eq!(
694 resolve_credential_store_with(None, None, &file),
695 CredentialStore::Keyring
696 );
697 }
698
699 #[test]
700 fn resolution_defaults_to_auto() {
701 assert_eq!(
702 resolve_credential_store_with(None, None, &EngineConfig::default()),
703 CredentialStore::Auto
704 );
705 }
706
707 #[test]
708 fn resolution_ignores_invalid_env_and_falls_through() {
709 let file = EngineConfig {
710 credentials: CredentialsConfig {
711 store: Some(CredentialStore::File),
712 },
713 ..Default::default()
714 };
715 assert_eq!(
717 resolve_credential_store_with(None, Some("garbage"), &file),
718 CredentialStore::File
719 );
720 assert_eq!(
722 resolve_credential_store_with(None, Some("garbage"), &EngineConfig::default()),
723 CredentialStore::Auto
724 );
725 }
726
727 #[test]
728 fn config_file_path_rejects_unsafe_app_id() {
729 assert_eq!(config_file_path("../evil"), None);
730 assert_eq!(config_file_path("a/b"), None);
731 }
732
733 #[test]
734 fn credential_store_flag_encodes_round_trips() {
735 for store in [
736 None,
737 Some(CredentialStore::Auto),
738 Some(CredentialStore::Keyring),
739 Some(CredentialStore::File),
740 ] {
741 assert_eq!(decode_store(encode_store(store)), store);
742 }
743 }
744
745 #[test]
746 fn config_file_path_uses_xdg_config_home() {
747 let dir = std::env::temp_dir().join("cli-engine-config-path-test");
748 test_env::with_xdg_config_home(&dir, || {
749 assert_eq!(
750 config_file_path("myapp"),
751 Some(dir.join("myapp").join("config.toml"))
752 );
753 });
754 }
755
756 #[derive(Debug, Deserialize, PartialEq)]
757 struct Deploy {
758 region: String,
759 replicas: u32,
760 }
761
762 fn doc_config(toml: &str) -> ConfigFile {
763 ConfigFile::from_doc(None, toml.parse().expect("valid toml"))
764 }
765
766 #[test]
767 fn section_reads_consumer_table() {
768 let cfg = doc_config("[deploy]\nregion = \"us-west\"\nreplicas = 3\n");
769 let deploy: Deploy = cfg.section("deploy").expect("ok").expect("present");
770 assert_eq!(
771 deploy,
772 Deploy {
773 region: "us-west".to_owned(),
774 replicas: 3
775 }
776 );
777 assert!(cfg.section::<Deploy>("absent").expect("ok").is_none());
778 }
779
780 #[test]
781 fn engine_and_consumer_sections_coexist() {
782 let cfg = doc_config(
783 "[credentials]\nstore = \"file\"\n[deploy]\nregion = \"eu\"\nreplicas = 1\n",
784 );
785 assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
786 assert_eq!(
787 cfg.section::<Deploy>("deploy")
788 .expect("ok")
789 .expect("present")
790 .region,
791 "eu"
792 );
793 }
794
795 #[test]
796 fn get_reads_dotted_scalar() {
797 let cfg = doc_config("[credentials]\nstore = \"file\"\n[deploy]\nreplicas = 3\n");
798 assert_eq!(cfg.get("credentials.store").as_deref(), Some("file"));
799 assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("3"));
800 assert_eq!(cfg.get("deploy.missing"), None);
801 assert_eq!(cfg.get("nope.at.all"), None);
802 }
803
804 #[test]
805 fn set_infers_scalar_types() {
806 let mut cfg = ConfigFile::default();
807 cfg.set("telemetry.enabled", "true").expect("set bool");
808 cfg.set("deploy.replicas", "5").expect("set int");
809 cfg.set("deploy.region", "us-west").expect("set str");
810 assert_eq!(cfg.get("telemetry.enabled").as_deref(), Some("true"));
811 assert_eq!(cfg.get("deploy.replicas").as_deref(), Some("5"));
812 assert_eq!(cfg.get("deploy.region").as_deref(), Some("us-west"));
813 assert!(cfg.doc.to_string().contains("enabled = true"));
815 assert!(cfg.doc.to_string().contains("replicas = 5"));
816 }
817
818 #[test]
819 fn set_validates_engine_store_key() {
820 let mut cfg = ConfigFile::default();
821 assert!(cfg.set("credentials.store", "bogus").is_err());
822 assert!(cfg.set("credentials.store", "file").is_ok());
823 assert_eq!(cfg.engine().credentials.store, Some(CredentialStore::File));
824 }
825
826 #[test]
827 fn set_rejects_unknown_engine_reserved_keys() {
828 let mut cfg = ConfigFile::default();
829 assert!(
831 cfg.set("credentials.unknown_future_key", "foo").is_err(),
832 "unknown credentials key should be rejected"
833 );
834 assert!(
835 cfg.set("credentials.timeout", "30").is_err(),
836 "unknown credentials.timeout should be rejected"
837 );
838 assert!(
840 cfg.set("deploy.region", "us-west").is_ok(),
841 "consumer-owned keys should be accepted"
842 );
843 }
844
845 #[test]
846 fn set_rejects_empty_key_segments() {
847 let mut cfg = ConfigFile::default();
848 assert!(cfg.set("a..b", "x").is_err());
849 assert!(cfg.set("", "x").is_err());
850 }
851
852 #[test]
853 fn set_preserves_comments_and_other_tables() {
854 let mut cfg =
855 doc_config("# keep me\n[credentials]\nstore = \"file\"\n\n[deploy]\nregion = \"us\"\n");
856 cfg.set("deploy.region", "eu").expect("set");
857 let rendered = cfg.doc.to_string();
858 assert!(
859 rendered.contains("# keep me"),
860 "comment preserved: {rendered}"
861 );
862 assert!(
863 rendered.contains("store = \"file\""),
864 "other table preserved"
865 );
866 assert!(rendered.contains("region = \"eu\""), "value updated");
867 }
868
869 #[test]
870 fn load_and_save_round_trip() {
871 let dir = tempfile::tempdir().expect("tempdir");
872 test_env::with_xdg_config_home(dir.path(), || {
873 let mut cfg = ConfigFile::load("roundtrip");
874 assert!(cfg.path().is_some());
875 cfg.set("deploy.region", "us-west").expect("set");
876 cfg.save().expect("save");
877 let reloaded = ConfigFile::load("roundtrip");
879 assert_eq!(reloaded.get("deploy.region").as_deref(), Some("us-west"));
880 });
881 }
882
883 #[test]
884 fn malformed_file_loads_as_empty() {
885 let dir = tempfile::tempdir().expect("tempdir");
886 test_env::with_xdg_config_home(dir.path(), || {
887 let path = config_file_path("broken").expect("path");
888 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
889 std::fs::write(&path, "not = valid = toml").expect("write");
890 let cfg = ConfigFile::load("broken");
891 assert_eq!(cfg.engine().credentials.store, None);
892 assert_eq!(cfg.get("anything"), None);
893 });
894 }
895
896 #[test]
897 fn default_config_has_no_path_and_save_errors() {
898 let cfg = ConfigFile::default();
899 assert!(cfg.path().is_none());
900 assert!(cfg.save().is_err(), "save without a path should error");
901 }
902}