1use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29
30use crate::{Request, SendraError};
31
32const CONFIG_FILE_NAME: &str = "config.yaml";
35
36pub(crate) const PROJECT_DIR_NAME: &str = ".sendra";
42
43const APP_DIR_NAME: &str = "sendra";
45
46pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
53
54pub const DEFAULT_MAX_REDIRECTS: u32 = 10;
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum FollowRedirects {
77 Follow(u32),
79 Disabled,
82}
83
84impl Default for FollowRedirects {
85 fn default() -> Self {
86 FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
87 }
88}
89
90impl<'de> Deserialize<'de> for FollowRedirects {
94 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
95 where
96 D: serde::Deserializer<'de>,
97 {
98 struct FollowRedirectsVisitor;
99
100 impl serde::de::Visitor<'_> for FollowRedirectsVisitor {
101 type Value = FollowRedirects;
102
103 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str("`true`, `false`, or a maximum number of redirects to follow")
105 }
106
107 fn visit_bool<E: serde::de::Error>(self, value: bool) -> Result<Self::Value, E> {
108 Ok(if value {
109 FollowRedirects::default()
110 } else {
111 FollowRedirects::Disabled
112 })
113 }
114
115 fn visit_u64<E: serde::de::Error>(self, value: u64) -> Result<Self::Value, E> {
116 u32::try_from(value)
117 .map(FollowRedirects::Follow)
118 .map_err(|_| E::custom("redirect limit is too large"))
119 }
120
121 fn visit_i64<E: serde::de::Error>(self, value: i64) -> Result<Self::Value, E> {
122 if value < 0 {
123 return Err(E::custom("redirect limit cannot be negative"));
124 }
125 self.visit_u64(value as u64)
126 }
127 }
128
129 deserializer.deserialize_any(FollowRedirectsVisitor)
130 }
131}
132
133impl Serialize for FollowRedirects {
138 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
139 where
140 S: serde::Serializer,
141 {
142 match self {
143 FollowRedirects::Disabled => serializer.serialize_bool(false),
144 FollowRedirects::Follow(max) => serializer.serialize_u32(*max),
145 }
146 }
147}
148
149#[cfg(feature = "schema")]
155impl schemars::JsonSchema for FollowRedirects {
156 fn schema_name() -> std::borrow::Cow<'static, str> {
157 "FollowRedirects".into()
158 }
159
160 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
161 schemars::json_schema!({
162 "description": "Whether to follow redirects: `false` to report a 3xx response as-is, \
163 `true` to follow up to the default maximum, or a non-negative integer maximum \
164 number of hops.",
165 "anyOf": [
166 { "type": "boolean" },
167 { "type": "integer", "minimum": 0 }
168 ]
169 })
170 }
171}
172
173#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
194#[serde(deny_unknown_fields)]
195pub struct ConfigFile {
196 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
199 pub headers: BTreeMap<String, String>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub timeout_seconds: Option<u64>,
209
210 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub follow_redirects: Option<FollowRedirects>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub insecure: Option<bool>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub proxy: Option<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub client_cert: Option<ClientCertFile>,
294
295 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub cookie_jar: Option<bool>,
312}
313
314#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
319#[serde(deny_unknown_fields)]
320pub struct ClientCertFile {
321 pub cert: String,
322 pub key: String,
323}
324
325fn resolve_client_cert_paths(file: &mut ConfigFile, config_path: &Path) {
335 let Some(client_cert) = &mut file.client_cert else {
336 return;
337 };
338 let base = config_path
339 .parent()
340 .filter(|dir| !dir.as_os_str().is_empty())
341 .unwrap_or_else(|| Path::new("."));
342 for field in [&mut client_cert.cert, &mut client_cert.key] {
343 let candidate = Path::new(field.as_str());
344 if candidate.is_relative() {
345 *field = base.join(candidate).to_string_lossy().into_owned();
346 }
347 }
348}
349
350impl ConfigFile {
351 pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
353 Self::parse(yaml, SendraError::ParseStr)
354 }
355
356 pub fn from_path(path: impl AsRef<Path>) -> Result<Self, SendraError> {
358 let path = path.as_ref();
359 let raw = std::fs::read_to_string(path).map_err(|source| SendraError::ConfigIo {
360 path: path.to_path_buf(),
361 source,
362 })?;
363 Self::parse(&raw, |source| SendraError::ConfigParse {
364 path: path.to_path_buf(),
365 source,
366 })
367 }
368
369 fn parse(
372 yaml: &str,
373 wrap: impl Fn(serde_yaml::Error) -> SendraError,
374 ) -> Result<Self, SendraError> {
375 let probe: serde_yaml::Value = serde_yaml::from_str(yaml).map_err(&wrap)?;
381 if probe.is_null() {
382 return Ok(Self::default());
383 }
384 serde_yaml::from_str(yaml).map_err(&wrap)
385 }
386
387 fn merge_over(self, base: Self) -> Self {
394 let mut headers = base.headers;
395 for (name, value) in self.headers {
396 insert_overriding(&mut headers, &name, &value);
397 }
398
399 Self {
400 headers,
401 timeout_seconds: self.timeout_seconds.or(base.timeout_seconds),
402 follow_redirects: self.follow_redirects.or(base.follow_redirects),
403 insecure: self.insecure.or(base.insecure),
404 proxy: self.proxy.or(base.proxy),
405 client_cert: self.client_cert.or(base.client_cert),
406 cookie_jar: self.cookie_jar.or(base.cookie_jar),
407 }
408 }
409}
410
411#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct Config {
417 pub headers: BTreeMap<String, String>,
419 pub timeout: Duration,
421 pub redirects: FollowRedirects,
423 pub insecure: bool,
425 pub proxy: Option<String>,
428 pub client_cert: Option<PathBuf>,
433 pub client_key: Option<PathBuf>,
435 pub cookie_jar: bool,
438 pub sources: Vec<PathBuf>,
441}
442
443impl Default for Config {
444 fn default() -> Self {
450 Self {
451 headers: BTreeMap::new(),
452 timeout: DEFAULT_TIMEOUT,
453 redirects: FollowRedirects::default(),
454 insecure: false,
455 proxy: None,
456 client_cert: None,
457 client_key: None,
458 cookie_jar: false,
459 sources: Vec::new(),
460 }
461 }
462}
463
464impl Config {
465 pub fn resolve() -> Result<Self, SendraError> {
473 let cwd = std::env::current_dir().map_err(SendraError::CurrentDir)?;
474 Self::resolve_from(&cwd, global_config_path().as_deref())
475 }
476
477 pub fn resolve_from(
489 start_dir: &Path,
490 global_config: Option<&Path>,
491 ) -> Result<Self, SendraError> {
492 let mut sources = Vec::new();
493 let mut merged = ConfigFile::default();
494
495 for path in [
497 global_config.map(Path::to_path_buf),
498 find_project_config(start_dir),
499 ]
500 .into_iter()
501 .flatten()
502 {
503 if !path.is_file() {
504 continue;
505 }
506 let mut file = ConfigFile::from_path(&path)?;
507 resolve_client_cert_paths(&mut file, &path);
508 merged = file.merge_over(merged);
509 sources.push(path);
510 }
511
512 let (client_cert, client_key) = match merged.client_cert {
513 Some(client_cert) => (
514 Some(PathBuf::from(client_cert.cert)),
515 Some(PathBuf::from(client_cert.key)),
516 ),
517 None => (None, None),
518 };
519
520 Ok(Self {
521 headers: merged.headers,
522 timeout: merged
523 .timeout_seconds
524 .map_or(DEFAULT_TIMEOUT, Duration::from_secs),
525 redirects: merged.follow_redirects.unwrap_or_default(),
526 insecure: merged.insecure.unwrap_or(false),
527 proxy: merged.proxy,
528 client_cert,
529 client_key,
530 cookie_jar: merged.cookie_jar.unwrap_or(false),
531 sources,
532 })
533 }
534
535 pub fn apply(&self, request: &Request) -> Request {
552 let mut applied = request.clone();
553 for (name, value) in &self.headers {
554 insert_if_absent(&mut applied.headers, name, value);
555 }
556 applied
557 }
558}
559
560pub(crate) fn insert_if_absent(headers: &mut Vec<(String, String)>, name: &str, value: &str) {
568 if headers
569 .iter()
570 .any(|(existing, _)| existing.eq_ignore_ascii_case(name))
571 {
572 return;
573 }
574 headers.push((name.to_string(), value.to_string()));
575}
576
577fn insert_overriding(headers: &mut BTreeMap<String, String>, name: &str, value: &str) {
580 headers.retain(|existing, _| !existing.eq_ignore_ascii_case(name));
581 headers.insert(name.to_string(), value.to_string());
582}
583
584pub fn find_project_config(start_dir: &Path) -> Option<PathBuf> {
599 start_dir
600 .ancestors()
601 .map(|dir| dir.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME))
602 .find(|candidate| candidate.is_file())
603}
604
605pub fn global_config_path() -> Option<PathBuf> {
617 let root = match std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from) {
618 Some(dir) if dir.is_absolute() => dir,
619 _ => dirs::config_dir()?,
620 };
621 Some(root.join(APP_DIR_NAME).join(CONFIG_FILE_NAME))
622}
623
624#[cfg(test)]
625mod tests {
626 use super::*;
627
628 use crate::Method;
629
630 fn write(path: &Path, contents: &str) {
632 std::fs::create_dir_all(path.parent().expect("a file has a parent")).unwrap();
633 std::fs::write(path, contents).unwrap();
634 }
635
636 fn project(dir: &Path, config: &str) -> PathBuf {
638 let root = dir.join("project");
639 write(&root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME), config);
640 root
641 }
642
643 fn global(dir: &Path, config: &str) -> PathBuf {
645 let path = dir.join("global").join(APP_DIR_NAME).join(CONFIG_FILE_NAME);
646 write(&path, config);
647 path
648 }
649
650 fn request_with_headers(headers: &[(&str, &str)]) -> Request {
651 Request {
652 name: None,
653 method: Method::Get,
654 url: "https://example.com".to_string(),
655 headers: headers
656 .iter()
657 .map(|(name, value)| (name.to_string(), value.to_string()))
658 .collect(),
659 query: Vec::new(),
660 body: None,
661 json: None,
662 body_file: None,
663 form: Vec::new(),
664 multipart: Vec::new(),
665 auth: None,
666 assertions: None,
667 pre_request: None,
668 post_request: None,
669 capture: None,
670 retry: None,
671 }
672 }
673
674 #[test]
675 fn no_config_anywhere_falls_back_to_the_hardcoded_defaults() {
676 let temp = tempfile::tempdir().unwrap();
677 let missing = temp.path().join("nowhere").join(CONFIG_FILE_NAME);
680
681 let config = Config::resolve_from(temp.path(), Some(&missing))
682 .expect("no config file is not a failure");
683
684 assert_eq!(config, Config::default());
685 assert!(config.headers.is_empty());
686 assert_eq!(config.timeout, DEFAULT_TIMEOUT);
687 assert!(config.sources.is_empty(), "nothing was read");
688 }
689
690 #[test]
691 fn a_global_config_applies_when_there_is_no_project_config() {
692 let temp = tempfile::tempdir().unwrap();
693 let global = global(
694 temp.path(),
695 "headers:\n User-Agent: sendra-global\ntimeout_seconds: 5\n",
696 );
697 let elsewhere = temp.path().join("elsewhere");
699 std::fs::create_dir_all(&elsewhere).unwrap();
700
701 let config = Config::resolve_from(&elsewhere, Some(&global)).unwrap();
702
703 assert_eq!(
704 config.headers.get("User-Agent").map(String::as_str),
705 Some("sendra-global")
706 );
707 assert_eq!(config.timeout, Duration::from_secs(5));
708 assert_eq!(config.sources, vec![global]);
709 }
710
711 #[test]
712 fn a_project_config_applies_when_there_is_no_global_config() {
713 let temp = tempfile::tempdir().unwrap();
714 let root = project(
715 temp.path(),
716 "headers:\n X-Project: yes\ntimeout_seconds: 7\n",
717 );
718
719 let config = Config::resolve_from(&root, None).expect("no global config is fine");
720
721 assert_eq!(
722 config.headers.get("X-Project").map(String::as_str),
723 Some("yes")
724 );
725 assert_eq!(config.timeout, Duration::from_secs(7));
726 assert_eq!(
727 config.sources,
728 vec![root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME)]
729 );
730 }
731
732 #[test]
733 fn project_values_override_global_values_key_by_key_not_file_by_file() {
734 let temp = tempfile::tempdir().unwrap();
735 let global = global(
737 temp.path(),
738 "headers:\n User-Agent: sendra-global\n Accept: application/json\ntimeout_seconds: 60\n",
739 );
740 let root = project(temp.path(), "timeout_seconds: 3\n");
741
742 let config = Config::resolve_from(&root, Some(&global)).unwrap();
743
744 assert_eq!(config.timeout, Duration::from_secs(3));
746 assert_eq!(
750 config.headers.get("User-Agent").map(String::as_str),
751 Some("sendra-global")
752 );
753 assert_eq!(
754 config.headers.get("Accept").map(String::as_str),
755 Some("application/json")
756 );
757 assert_eq!(config.sources.len(), 2, "both files were read");
758 }
759
760 #[test]
761 fn header_maps_merge_per_key_too() {
762 let temp = tempfile::tempdir().unwrap();
763 let global = global(
764 temp.path(),
765 "headers:\n User-Agent: sendra-global\n Accept: application/json\n",
766 );
767 let root = project(temp.path(), "headers:\n User-Agent: sendra-project\n");
769
770 let config = Config::resolve_from(&root, Some(&global)).unwrap();
771
772 assert_eq!(
773 config.headers.get("User-Agent").map(String::as_str),
774 Some("sendra-project")
775 );
776 assert_eq!(
777 config.headers.get("Accept").map(String::as_str),
778 Some("application/json")
779 );
780 assert_eq!(config.timeout, DEFAULT_TIMEOUT);
782 }
783
784 #[test]
785 fn a_project_header_overrides_a_global_one_spelled_with_different_casing() {
786 let temp = tempfile::tempdir().unwrap();
787 let global = global(temp.path(), "headers:\n User-Agent: sendra-global\n");
788 let root = project(temp.path(), "headers:\n user-agent: sendra-project\n");
789
790 let config = Config::resolve_from(&root, Some(&global)).unwrap();
791
792 assert_eq!(config.headers.len(), 1, "got {:?}", config.headers);
794 assert_eq!(
795 config.headers.values().next().map(String::as_str),
796 Some("sendra-project")
797 );
798 }
799
800 #[test]
801 fn the_config_at_the_project_root_is_found_from_a_nested_subdirectory() {
802 let temp = tempfile::tempdir().unwrap();
803 let root = project(temp.path(), "headers:\n X-Project: yes\n");
804 let nested = root.join("crates").join("api").join("tests");
806 std::fs::create_dir_all(&nested).unwrap();
807
808 let found = find_project_config(&nested).expect("the walk-up must reach the root");
809 assert_eq!(
810 found,
811 root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
812 "the config at the project root should have been found from {}",
813 nested.display()
814 );
815
816 assert_eq!(
818 Config::resolve_from(&nested, None).unwrap().headers,
819 Config::resolve_from(&root, None).unwrap().headers
820 );
821 }
822
823 #[test]
824 fn the_nearest_project_config_wins_over_one_further_up() {
825 let temp = tempfile::tempdir().unwrap();
826 let outer = project(temp.path(), "headers:\n X-Which: outer\n");
827 let inner = outer.join("nested");
828 write(
829 &inner.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
830 "headers:\n X-Which: inner\n",
831 );
832
833 let config = Config::resolve_from(&inner, None).unwrap();
834 assert_eq!(
835 config.headers.get("X-Which").map(String::as_str),
836 Some("inner")
837 );
838 assert_eq!(config.sources.len(), 1, "only the nearest is read");
839 }
840
841 #[test]
842 fn malformed_yaml_in_a_config_file_is_a_typed_error_carrying_the_path() {
843 let temp = tempfile::tempdir().unwrap();
844 let root = project(temp.path(), "headers: [oops\n");
846
847 let err = Config::resolve_from(&root, None).expect_err("malformed config must error");
848 match err {
849 SendraError::ConfigParse { path, .. } => assert_eq!(
850 path,
851 root.join(PROJECT_DIR_NAME).join(CONFIG_FILE_NAME),
852 "the error should name the file to fix"
853 ),
854 other => panic!("expected ConfigParse, got {other:?}"),
855 }
856 }
857
858 #[test]
859 fn an_unknown_config_key_is_rejected_rather_than_ignored() {
860 let temp = tempfile::tempdir().unwrap();
861 let root = project(temp.path(), "timeout: 5\n");
864
865 let err = Config::resolve_from(&root, None).expect_err("a typo must not be ignored");
866 assert!(
867 matches!(err, SendraError::ConfigParse { .. }),
868 "got {err:?}"
869 );
870 }
871
872 #[test]
873 fn a_wrongly_typed_config_value_is_a_parse_error() {
874 let err = ConfigFile::from_yaml_str("timeout_seconds: soon\n")
875 .expect_err("seconds must be a number");
876 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
877 }
878
879 #[test]
882 fn no_follow_redirects_key_resolves_to_the_default_of_ten() {
883 let temp = tempfile::tempdir().unwrap();
884 let root = project(temp.path(), "timeout_seconds: 5\n");
885
886 let config = Config::resolve_from(&root, None).unwrap();
887
888 assert_eq!(
889 config.redirects,
890 FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
891 );
892 }
893
894 #[test]
895 fn follow_redirects_false_disables_them() {
896 let temp = tempfile::tempdir().unwrap();
897 let root = project(temp.path(), "follow_redirects: false\n");
898
899 let config = Config::resolve_from(&root, None).unwrap();
900
901 assert_eq!(config.redirects, FollowRedirects::Disabled);
902 }
903
904 #[test]
905 fn follow_redirects_true_is_the_same_default_maximum() {
906 let temp = tempfile::tempdir().unwrap();
907 let root = project(temp.path(), "follow_redirects: true\n");
908
909 let config = Config::resolve_from(&root, None).unwrap();
910
911 assert_eq!(
912 config.redirects,
913 FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
914 );
915 }
916
917 #[test]
918 fn follow_redirects_as_a_number_sets_a_custom_maximum() {
919 let temp = tempfile::tempdir().unwrap();
920 let root = project(temp.path(), "follow_redirects: 3\n");
921
922 let config = Config::resolve_from(&root, None).unwrap();
923
924 assert_eq!(config.redirects, FollowRedirects::Follow(3));
925 }
926
927 #[test]
928 fn a_project_follow_redirects_overrides_a_global_one_wholesale() {
929 let temp = tempfile::tempdir().unwrap();
933 let global = global(temp.path(), "follow_redirects: false\n");
934 let root = project(temp.path(), "follow_redirects: 2\n");
935
936 let config = Config::resolve_from(&root, Some(&global)).unwrap();
937
938 assert_eq!(config.redirects, FollowRedirects::Follow(2));
939 }
940
941 #[test]
942 fn a_negative_follow_redirects_number_is_a_parse_error() {
943 let err = ConfigFile::from_yaml_str("follow_redirects: -1\n")
944 .expect_err("a negative redirect count makes no sense");
945 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
946 }
947
948 #[test]
949 fn a_follow_redirects_value_that_is_neither_bool_nor_number_says_so() {
950 let err = ConfigFile::from_yaml_str("follow_redirects: sometimes\n")
951 .expect_err("a string is not a valid value");
952 let message = err.to_string();
953 assert!(
954 message.contains("could not parse"),
955 "got {message}: {err:?}"
956 );
957 }
958
959 #[test]
962 fn no_insecure_key_resolves_to_false() {
963 let temp = tempfile::tempdir().unwrap();
964 let root = project(temp.path(), "timeout_seconds: 5\n");
965
966 let config = Config::resolve_from(&root, None).unwrap();
967
968 assert!(!config.insecure);
969 }
970
971 #[test]
972 fn insecure_true_resolves_to_true() {
973 let temp = tempfile::tempdir().unwrap();
974 let root = project(temp.path(), "insecure: true\n");
975
976 let config = Config::resolve_from(&root, None).unwrap();
977
978 assert!(config.insecure);
979 }
980
981 #[test]
982 fn a_project_insecure_overrides_a_global_one_wholesale() {
983 let temp = tempfile::tempdir().unwrap();
984 let global = global(temp.path(), "insecure: true\n");
985 let root = project(temp.path(), "insecure: false\n");
986
987 let config = Config::resolve_from(&root, Some(&global)).unwrap();
988
989 assert!(!config.insecure, "the project's explicit false must win");
990 }
991
992 #[test]
993 fn a_global_insecure_applies_when_the_project_says_nothing() {
994 let temp = tempfile::tempdir().unwrap();
995 let global = global(temp.path(), "insecure: true\n");
996 let root = project(temp.path(), "timeout_seconds: 5\n");
997
998 let config = Config::resolve_from(&root, Some(&global)).unwrap();
999
1000 assert!(config.insecure);
1001 }
1002
1003 #[test]
1006 fn no_cookie_jar_key_resolves_to_false() {
1007 let temp = tempfile::tempdir().unwrap();
1008 let root = project(temp.path(), "timeout_seconds: 5\n");
1009
1010 let config = Config::resolve_from(&root, None).unwrap();
1011
1012 assert!(!config.cookie_jar);
1013 }
1014
1015 #[test]
1016 fn cookie_jar_true_resolves_to_true() {
1017 let temp = tempfile::tempdir().unwrap();
1018 let root = project(temp.path(), "cookie_jar: true\n");
1019
1020 let config = Config::resolve_from(&root, None).unwrap();
1021
1022 assert!(config.cookie_jar);
1023 }
1024
1025 #[test]
1026 fn a_project_cookie_jar_overrides_a_global_one_wholesale() {
1027 let temp = tempfile::tempdir().unwrap();
1028 let global = global(temp.path(), "cookie_jar: true\n");
1029 let root = project(temp.path(), "cookie_jar: false\n");
1030
1031 let config = Config::resolve_from(&root, Some(&global)).unwrap();
1032
1033 assert!(!config.cookie_jar, "the project's explicit false must win");
1034 }
1035
1036 #[test]
1037 fn a_global_cookie_jar_applies_when_the_project_says_nothing() {
1038 let temp = tempfile::tempdir().unwrap();
1039 let global = global(temp.path(), "cookie_jar: true\n");
1040 let root = project(temp.path(), "timeout_seconds: 5\n");
1041
1042 let config = Config::resolve_from(&root, Some(&global)).unwrap();
1043
1044 assert!(config.cookie_jar);
1045 }
1046
1047 #[test]
1050 fn no_proxy_key_resolves_to_none() {
1051 let temp = tempfile::tempdir().unwrap();
1052 let root = project(temp.path(), "timeout_seconds: 5\n");
1053
1054 let config = Config::resolve_from(&root, None).unwrap();
1055
1056 assert_eq!(config.proxy, None);
1057 }
1058
1059 #[test]
1060 fn proxy_resolves_to_the_configured_url() {
1061 let temp = tempfile::tempdir().unwrap();
1062 let root = project(temp.path(), "proxy: http://proxy.example.com:8080\n");
1063
1064 let config = Config::resolve_from(&root, None).unwrap();
1065
1066 assert_eq!(
1067 config.proxy.as_deref(),
1068 Some("http://proxy.example.com:8080")
1069 );
1070 }
1071
1072 #[test]
1073 fn a_proxy_url_with_embedded_credentials_round_trips_unchanged() {
1074 let temp = tempfile::tempdir().unwrap();
1077 let root = project(
1078 temp.path(),
1079 "proxy: http://user:pass@proxy.example.com:8080\n",
1080 );
1081
1082 let config = Config::resolve_from(&root, None).unwrap();
1083
1084 assert_eq!(
1085 config.proxy.as_deref(),
1086 Some("http://user:pass@proxy.example.com:8080")
1087 );
1088 }
1089
1090 #[test]
1091 fn a_project_proxy_overrides_a_global_one_wholesale() {
1092 let temp = tempfile::tempdir().unwrap();
1093 let global = global(temp.path(), "proxy: http://global-proxy:8080\n");
1094 let root = project(temp.path(), "proxy: http://project-proxy:8080\n");
1095
1096 let config = Config::resolve_from(&root, Some(&global)).unwrap();
1097
1098 assert_eq!(config.proxy.as_deref(), Some("http://project-proxy:8080"));
1099 }
1100
1101 #[test]
1104 fn no_client_cert_key_resolves_to_neither_cert_nor_key() {
1105 let temp = tempfile::tempdir().unwrap();
1106 let root = project(temp.path(), "timeout_seconds: 5\n");
1107
1108 let config = Config::resolve_from(&root, None).unwrap();
1109
1110 assert_eq!(config.client_cert, None);
1111 assert_eq!(config.client_key, None);
1112 }
1113
1114 #[test]
1115 fn a_relative_client_cert_resolves_against_the_project_configs_own_directory() {
1116 let temp = tempfile::tempdir().unwrap();
1117 let root = project(
1118 temp.path(),
1119 "client_cert:\n cert: ./client.pem\n key: ./client-key.pem\n",
1120 );
1121
1122 let config = Config::resolve_from(&root, None).unwrap();
1123
1124 assert_eq!(
1127 config.client_cert,
1128 Some(root.join(PROJECT_DIR_NAME).join("client.pem"))
1129 );
1130 assert_eq!(
1131 config.client_key,
1132 Some(root.join(PROJECT_DIR_NAME).join("client-key.pem"))
1133 );
1134 }
1135
1136 #[test]
1137 fn a_relative_client_cert_resolves_against_the_global_configs_own_directory_not_the_project() {
1138 let temp = tempfile::tempdir().unwrap();
1142 let global = global(
1143 temp.path(),
1144 "client_cert:\n cert: ./g.pem\n key: ./g-key.pem\n",
1145 );
1146 let root = project(temp.path(), "timeout_seconds: 5\n");
1147
1148 let config = Config::resolve_from(&root, Some(&global)).unwrap();
1149
1150 assert_eq!(
1151 config.client_cert,
1152 Some(global.parent().unwrap().join("g.pem"))
1153 );
1154 assert_eq!(
1155 config.client_key,
1156 Some(global.parent().unwrap().join("g-key.pem"))
1157 );
1158 }
1159
1160 #[test]
1161 fn an_absolute_client_cert_path_is_left_unchanged() {
1162 let temp = tempfile::tempdir().unwrap();
1163 let absolute = temp.path().join("elsewhere").join("client.pem");
1164 let root = project(
1167 temp.path(),
1168 &format!(
1169 "client_cert:\n cert: {}\n key: ./client-key.pem\n",
1170 absolute.display()
1171 ),
1172 );
1173
1174 let config = Config::resolve_from(&root, None).unwrap();
1175
1176 assert_eq!(config.client_cert, Some(absolute));
1177 }
1178
1179 #[test]
1180 fn a_project_client_cert_overrides_a_global_one_wholesale() {
1181 let temp = tempfile::tempdir().unwrap();
1182 let global = global(
1183 temp.path(),
1184 "client_cert:\n cert: ./g.pem\n key: ./g-key.pem\n",
1185 );
1186 let root = project(
1187 temp.path(),
1188 "client_cert:\n cert: ./p.pem\n key: ./p-key.pem\n",
1189 );
1190
1191 let config = Config::resolve_from(&root, Some(&global)).unwrap();
1192
1193 assert_eq!(
1194 config.client_cert,
1195 Some(root.join(PROJECT_DIR_NAME).join("p.pem"))
1196 );
1197 assert_eq!(
1198 config.client_key,
1199 Some(root.join(PROJECT_DIR_NAME).join("p-key.pem"))
1200 );
1201 }
1202
1203 #[test]
1204 fn client_cert_with_only_a_cert_key_is_a_parse_error() {
1205 let err = ConfigFile::from_yaml_str("client_cert:\n cert: ./c.pem\n")
1211 .expect_err("`key` is required alongside `cert`");
1212 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
1213 }
1214
1215 #[test]
1216 fn an_unknown_key_inside_client_cert_is_rejected() {
1217 let err = ConfigFile::from_yaml_str(
1218 "client_cert:\n cert: ./c.pem\n key: ./k.pem\n password: hunter2\n",
1219 )
1220 .expect_err("`password` is not a known field of `client_cert`");
1221 assert!(matches!(err, SendraError::ParseStr(_)), "got {err:?}");
1222 }
1223
1224 #[test]
1225 fn an_unknown_config_key_near_proxy_or_insecure_is_still_rejected() {
1226 let temp = tempfile::tempdir().unwrap();
1227 let root = project(temp.path(), "insecur: true\n");
1228
1229 let err = Config::resolve_from(&root, None).expect_err("a typo must not be ignored");
1230 assert!(matches!(err, SendraError::ConfigParse { .. }), "{err:?}");
1231 }
1232
1233 #[test]
1234 fn an_empty_config_file_is_an_empty_config_not_an_error() {
1235 let temp = tempfile::tempdir().unwrap();
1236 let root = project(temp.path(), "# nothing set yet\n");
1237
1238 let config = Config::resolve_from(&root, None).expect("an empty file is valid");
1239 assert_eq!(config.headers, BTreeMap::new());
1240 assert_eq!(config.timeout, DEFAULT_TIMEOUT);
1241 assert_eq!(config.sources.len(), 1);
1243 }
1244
1245 #[test]
1246 fn config_headers_are_added_to_a_request_that_does_not_set_them() {
1247 let config = Config {
1248 headers: BTreeMap::from([("User-Agent".to_string(), "sendra".to_string())]),
1249 ..Config::default()
1250 };
1251
1252 let applied = config.apply(&request_with_headers(&[("Accept", "text/plain")]));
1253
1254 assert_eq!(applied.header("User-Agent"), Some("sendra"));
1255 assert_eq!(applied.header("Accept"), Some("text/plain"));
1256 }
1257
1258 #[test]
1259 fn a_request_header_beats_the_config_default_of_the_same_name() {
1260 let config = Config {
1261 headers: BTreeMap::from([("User-Agent".to_string(), "from-config".to_string())]),
1262 ..Config::default()
1263 };
1264
1265 let applied = config.apply(&request_with_headers(&[("User-Agent", "from-request")]));
1266
1267 assert_eq!(applied.header("User-Agent"), Some("from-request"));
1268 }
1269
1270 #[test]
1271 fn a_request_header_beats_a_config_default_spelled_with_different_casing() {
1272 let config = Config {
1273 headers: BTreeMap::from([("User-Agent".to_string(), "from-config".to_string())]),
1274 ..Config::default()
1275 };
1276
1277 let applied = config.apply(&request_with_headers(&[("user-agent", "from-request")]));
1278
1279 assert_eq!(applied.headers.len(), 1, "got {:?}", applied.headers);
1282 assert_eq!(applied.header("user-agent"), Some("from-request"));
1283 }
1284
1285 #[test]
1286 fn a_config_default_is_suppressed_even_when_the_request_repeats_that_name() {
1287 let config = Config {
1291 headers: BTreeMap::from([("X-Tag".to_string(), "from-config".to_string())]),
1292 ..Config::default()
1293 };
1294 let mut request = request_with_headers(&[]);
1295 request.headers = vec![
1296 ("X-Tag".to_string(), "one".to_string()),
1297 ("X-Tag".to_string(), "two".to_string()),
1298 ];
1299
1300 let applied = config.apply(&request);
1301
1302 assert_eq!(
1303 applied.headers,
1304 vec![
1305 ("X-Tag".to_string(), "one".to_string()),
1306 ("X-Tag".to_string(), "two".to_string()),
1307 ],
1308 "got {:?}",
1309 applied.headers
1310 );
1311 }
1312
1313 #[test]
1314 fn a_request_that_repeats_a_header_keeps_both_after_config_is_applied() {
1315 let config = Config {
1318 headers: BTreeMap::from([("User-Agent".to_string(), "sendra".to_string())]),
1319 ..Config::default()
1320 };
1321 let mut request = request_with_headers(&[]);
1322 request.headers = vec![
1323 ("X-Forwarded-For".to_string(), "1.2.3.4".to_string()),
1324 ("X-Forwarded-For".to_string(), "5.6.7.8".to_string()),
1325 ];
1326
1327 let applied = config.apply(&request);
1328
1329 let forwarded: Vec<&str> = applied
1330 .headers
1331 .iter()
1332 .filter(|(name, _)| name == "X-Forwarded-For")
1333 .map(|(_, value)| value.as_str())
1334 .collect();
1335 assert_eq!(forwarded, vec!["1.2.3.4", "5.6.7.8"]);
1336 assert_eq!(applied.header("User-Agent"), Some("sendra"));
1337 }
1338
1339 #[test]
1340 fn applying_a_config_changes_nothing_else_about_the_request() {
1341 let config = Config {
1342 headers: BTreeMap::from([("X-Added".to_string(), "1".to_string())]),
1343 ..Config::default()
1344 };
1345 let request = Request {
1346 name: Some("Create".to_string()),
1347 method: Method::Post,
1348 url: "https://example.com/things".to_string(),
1349 headers: Vec::new(),
1350 query: Vec::new(),
1351 body: Some("{}".to_string()),
1352 json: None,
1353 body_file: None,
1354 form: Vec::new(),
1355 multipart: Vec::new(),
1356 auth: None,
1357 assertions: Some(crate::Assertions {
1361 status: Some(200),
1362 ..crate::Assertions::default()
1363 }),
1364 pre_request: Some(
1365 "request.url = request.url;
1366"
1367 .to_string(),
1368 ),
1369 post_request: Some(
1370 "// nothing
1371"
1372 .to_string(),
1373 ),
1374 capture: Some(
1375 [(
1376 "id".to_string(),
1377 crate::CaptureSource::JsonPath("$.id".to_string()),
1378 )]
1379 .into_iter()
1380 .collect(),
1381 ),
1382 retry: None,
1383 };
1384
1385 let applied = config.apply(&request);
1386
1387 assert_eq!(applied.name, request.name);
1388 assert_eq!(applied.method, request.method);
1389 assert_eq!(applied.url, request.url);
1390 assert_eq!(applied.body, request.body);
1391 assert_eq!(applied.pre_request, request.pre_request);
1392 assert_eq!(applied.post_request, request.post_request);
1393 assert_eq!(applied.assertions, request.assertions);
1394 }
1395
1396 #[test]
1397 fn the_default_config_leaves_a_request_untouched() {
1398 let request = request_with_headers(&[("Accept", "application/json")]);
1399 assert_eq!(Config::default().apply(&request), request);
1400 }
1401
1402 #[test]
1403 fn the_global_config_path_ends_where_it_should() {
1404 let Some(path) = global_config_path() else {
1406 return;
1409 };
1410 assert!(
1411 path.ends_with(Path::new(APP_DIR_NAME).join(CONFIG_FILE_NAME)),
1412 "got {}",
1413 path.display()
1414 );
1415 assert!(path.is_absolute(), "got {}", path.display());
1416 }
1417}