Skip to main content

sendra_core/
config.rs

1//! Tool-wide configuration: where Sendra's config files live, how a project
2//! config overrides a global one, and what the result does to a request.
3//!
4//! Two files, either or both of which may be absent:
5//!
6//! - **Project**: `.sendra/config.yaml`, found by walking up from the current
7//!   directory the way git looks for `.git`, so running from a subdirectory of
8//!   a project still finds the project's config.
9//! - **Global**: `config.yaml` under the platform's config directory — on
10//!   Linux `$XDG_CONFIG_HOME/sendra` (i.e. `~/.config/sendra` by default), on
11//!   macOS `~/Library/Application Support/sendra`, on Windows
12//!   `%APPDATA%\sendra`. See [`global_config_path`].
13//!
14//! Project values override global values **per key**, not per file: a project
15//! config that sets only a timeout still inherits the global default headers.
16//! No config file anywhere is a perfectly ordinary state — everything falls
17//! back to the hardcoded defaults in [`Config::default`].
18//!
19//! The schema stays small on purpose, and grows only as a feature actually
20//! needs a new key — [`ConfigFile`] documents the current full set. This
21//! module exists first to prove the *resolution* mechanism: every key
22//! resolves through the same global-then-project, per-key `merge_over`.
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29
30use crate::{Request, SendraError};
31
32/// File name of a config file, under `.sendra/` in a project and directly
33/// under the global config directory.
34const CONFIG_FILE_NAME: &str = "config.yaml";
35
36/// Directory a project keeps its Sendra files in: `config.yaml` directly
37/// inside it, and the environment files of
38/// [`crate::environment`] under `environments/`. A directory rather than a bare
39/// `.sendra.yaml` precisely so that the second of those had somewhere obvious
40/// to go.
41pub(crate) const PROJECT_DIR_NAME: &str = ".sendra";
42
43/// Name of the global config directory, under the platform config root.
44const APP_DIR_NAME: &str = "sendra";
45
46/// Timeout applied when no config file sets one.
47///
48/// 30 seconds: long enough that a slow-but-working API is not cut off, short
49/// enough that a hung connection fails within a coffee sip rather than hanging
50/// a script forever. reqwest applies no timeout at all by default, which is the
51/// one option a command-line tool should not have.
52pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
53
54/// reqwest's own default: up to 10 redirects in a chain before giving up.
55/// Sendra keeps this as its default too, so a config that never mentions
56/// `follow_redirects` behaves exactly as it always has.
57pub const DEFAULT_MAX_REDIRECTS: u32 = 10;
58
59/// How a run treats an HTTP redirect: follow up to some maximum number of
60/// hops, or not at all.
61///
62/// On disk this is the `follow_redirects` key, and it is deliberately
63/// bool-or-number rather than two separate keys:
64///
65/// ```text
66/// follow_redirects: false   # report the 3xx response itself, do not chase it
67/// follow_redirects: true    # follow, up to the default of 10 hops
68/// follow_redirects: 3       # follow, up to a custom maximum
69/// ```
70///
71/// Leaving the key out entirely is the same as `true`: [`Config::default`]
72/// resolves to [`FollowRedirects::Follow`] with [`DEFAULT_MAX_REDIRECTS`],
73/// matching reqwest's own default and so changing nothing for a config that
74/// does not touch this key.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum FollowRedirects {
77    /// Follow a redirect chain up to this many hops before it is an error.
78    Follow(u32),
79    /// Do not follow redirects at all: a 3xx response is reported as-is,
80    /// Location header and all, rather than chased.
81    Disabled,
82}
83
84impl Default for FollowRedirects {
85    fn default() -> Self {
86        FollowRedirects::Follow(DEFAULT_MAX_REDIRECTS)
87    }
88}
89
90/// Hand-written rather than `#[serde(untagged)]`, for the same reason as
91/// `Request`'s header values: a value of the wrong shape should say "expected
92/// `true`, `false`, or a number", not "data did not match any variant".
93impl<'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
133/// The inverse of the visitor above: `Disabled` writes back as `false`, and a
134/// maximum writes back as the plain number — `true` never round-trips as
135/// `true`, since a resolved maximum is exactly as meaningful and there is
136/// only one of these in a merged [`Config`] to write back out anyway.
137impl 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/// Hand-written to match the hand-written [`Deserialize`] impl above:
150/// `true`/`false`, or a non-negative integer maximum. The `minimum: 0` below
151/// is one of the few `Request::validate`-adjacent business rules a JSON
152/// Schema combinator can actually enforce, rather than merely document — see
153/// `follow_redirects: -1`'s own parse-time rejection, which this mirrors.
154#[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/// One config file, exactly as it appears on disk.
174///
175/// Every field is optional, and stays optional after parsing, because that
176/// optionality *is* the merge information: `None` means "this file said
177/// nothing about it", which is what lets a project file override one key
178/// without silently resetting the others. The all-decided resolved form is
179/// [`Config`].
180///
181/// ```text
182/// headers:                 # merged into every request; the request wins ties
183///   User-Agent: sendra
184///   Accept: application/json
185/// timeout_seconds: 10      # whole-request timeout, connect through body read
186/// ```
187///
188/// Unknown keys are rejected, matching [`Request`] and
189/// [`Collection`](crate::Collection): a typo in a config key would otherwise be
190/// a setting that silently never applies, which is worse here than in a request
191/// file — there is no response in which to notice it.
192#[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    /// Headers merged into every request. A header set by the request itself
197    /// wins.
198    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
199    pub headers: BTreeMap<String, String>,
200
201    /// Whole-request timeout in seconds.
202    ///
203    /// Seconds as an integer, with the unit in the key name, rather than a
204    /// duration string like `"30s"`: there is then nothing to parse, no way to
205    /// read the unit wrong, and no syntax to stay compatible with if a richer
206    /// duration format is wanted later.
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub timeout_seconds: Option<u64>,
209
210    /// Whether to follow redirects, and how many hops to allow. See
211    /// [`FollowRedirects`].
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub follow_redirects: Option<FollowRedirects>,
214
215    /// Skip TLS certificate verification for every request this run sends —
216    /// the config-file form of `--insecure`.
217    ///
218    /// A real security-relevant setting, not a convenience default: it exists
219    /// for a self-signed or otherwise untrusted endpoint (an internal
220    /// staging host, say) where there is no CA chain to verify against, and
221    /// it disables the one thing standing between a request and a
222    /// man-in-the-middle. See [`build_client`](crate::build_client) for where
223    /// it is applied, and the CLI's `--insecure` doc comment for the warning
224    /// printed whenever this resolves to `true`, from either source.
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub insecure: Option<bool>,
227
228    /// Route every request through this HTTP proxy — the config-file form of
229    /// `--proxy`.
230    ///
231    /// ```text
232    /// proxy: http://proxy.example.com:8080
233    /// proxy: http://user:pass@proxy.example.com:8080   # credentials in the URL
234    /// ```
235    ///
236    /// A plain URL string rather than a structured `{host, port, user, pass}`
237    /// object: reqwest, which actually builds the proxying connector, already
238    /// reads user/pass credentials straight out of the URL's userinfo, so a
239    /// second, Sendra-specific way to spell the same thing would be a second
240    /// thing to keep in sync with reqwest's own parsing rather than a real
241    /// capability. Not validated here — an unparsable URL surfaces as a
242    /// [`SendraError::Client`] when [`build_client`](crate::build_client)
243    /// tries to build the client, the same place every other
244    /// client-construction failure is reported.
245    ///
246    /// Setting this — from either the config file or `--proxy` — takes over
247    /// proxying for the run entirely: the standard `HTTP_PROXY`/
248    /// `HTTPS_PROXY`/`NO_PROXY` environment variables Sendra otherwise
249    /// respects by default (matching curl, and every other common HTTP tool)
250    /// are not consulted once an explicit proxy is configured. `None` — no
251    /// `proxy:` key and no `--proxy` — is the plain "follow the environment,
252    /// same as everyone else" default.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub proxy: Option<String>,
255
256    /// Present a client certificate for mutual TLS — the config-file form of
257    /// `--client-cert`/`--client-key`.
258    ///
259    /// ```text
260    /// client_cert:
261    ///   cert: ./client.pem
262    ///   key: ./client-key.pem
263    /// ```
264    ///
265    /// PEM only, not PKCS#12: Sendra's `reqwest` is built against `rustls-tls`
266    /// alone (no `native-tls`), and `reqwest::Identity`'s PKCS#12 and
267    /// split-PEM constructors both require `native-tls` — pulling that in
268    /// would mean shipping a second TLS backend just to accept one more input
269    /// format. `Identity::from_pem` (the one constructor `rustls-tls` does
270    /// expose) wants a single buffer containing both the certificate and its
271    /// private key, so [`build_client`](crate::build_client) reads both files
272    /// and concatenates them in memory before handing that buffer to reqwest.
273    ///
274    /// `cert`/`key` are resolved relative to *this config file's own
275    /// directory*, not the current working directory — the same rule
276    /// `body_file:` uses for the request file that names it, and for the same
277    /// reason: a project config checked into version control should mean the
278    /// same file on every machine it runs on, not whichever directory the
279    /// command happened to be typed from. See
280    /// [`resolve_client_cert_paths`]. `--client-cert`/`--client-key`, by
281    /// contrast, resolve relative to the working directory, matching how
282    /// every other CLI-supplied path (`--junit`, say) is read.
283    ///
284    /// Both halves are required together — a cert with no key, or a key with
285    /// no cert, is refused as [`SendraError::ClientCertIncomplete`] when
286    /// [`build_client`](crate::build_client) tries to use it, the same place
287    /// every other client-construction failure is reported. That check runs
288    /// after CLI overrides are folded in, so a config `cert:` paired with a
289    /// `--client-key` override (or vice versa) is a valid combination, not an
290    /// error — only ending up with just one side, from any mix of sources, is
291    /// refused.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub client_cert: Option<ClientCertFile>,
294
295    /// Persist cookies received via `Set-Cookie` and send them back
296    /// automatically on later requests to the same host — the config-file
297    /// form of `--cookie-jar`.
298    ///
299    /// **Opt-in, off by default**, deliberately matching curl: `curl` does
300    /// not carry cookies between requests unless you pass `-c`/`-b`
301    /// yourself, and Sendra follows the same convention rather than
302    /// defaulting to "on" because it would be convenient for the
303    /// login-flow case this exists for. See
304    /// [`build_client`](crate::build_client) for where it is applied.
305    ///
306    /// In-memory only, for the duration of one invocation — nothing is
307    /// written to disk, and nothing survives between separate `sendra run`/
308    /// `sendra test` invocations, the same "no persistence across
309    /// invocations" rule [`crate::capture`] already follows.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub cookie_jar: Option<bool>,
312}
313
314/// The `client_cert:` config key's on-disk shape: a cert path and a key
315/// path, both plain strings so [`resolve_client_cert_paths`] can rewrite a
316/// relative one in place before it is ever turned into a [`PathBuf`].
317#[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
325/// Resolve `client_cert.cert`/`client_cert.key` in `file`, if set and
326/// relative, against the directory containing `config_path` — the config
327/// file that named them, not the current working directory.
328///
329/// Called immediately after each config file is read, while the path it came
330/// from is still in scope: by the time [`ConfigFile::merge_over`] runs, a
331/// project config's `client_cert:` and a global config's `client_cert:` may
332/// already have been resolved against two different base directories, and
333/// merging must not have to guess which one a given value came from.
334fn 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    /// Parse a config file from a YAML string.
352    pub fn from_yaml_str(yaml: &str) -> Result<Self, SendraError> {
353        Self::parse(yaml, SendraError::ParseStr)
354    }
355
356    /// Read and parse a config file from disk.
357    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    /// Shared body of the two constructors; `wrap` supplies the error variant
370    /// that says where the YAML came from.
371    fn parse(
372        yaml: &str,
373        wrap: impl Fn(serde_yaml::Error) -> SendraError,
374    ) -> Result<Self, SendraError> {
375        // An empty file (or one that is nothing but comments) is YAML null,
376        // which serde cannot deserialize into a struct even when every field
377        // has a default. Creating `.sendra/config.yaml` and filling it in later
378        // is too reasonable a thing to do for it to be an error, so null is
379        // read as an empty config.
380        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    /// Merge `self` over `base`, key by key, with `self` winning.
388    ///
389    /// Per key rather than per file: a project config that sets only
390    /// `timeout_seconds` must not discard the global config's `headers`. The
391    /// header maps merge the same way one level down, so a project can override
392    /// one default header without dropping the rest.
393    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/// Resolved, ready-to-use configuration: every field decided, no `Option`s
412/// left. Built by merging whichever config files exist over the hardcoded
413/// defaults, so the rest of the crate never has to ask whether a file was
414/// found.
415#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct Config {
417    /// Headers added to every request that does not set them itself.
418    pub headers: BTreeMap<String, String>,
419    /// Whole-request timeout: connect, send and body read.
420    pub timeout: Duration,
421    /// Whether to follow redirects, and how many hops to allow.
422    pub redirects: FollowRedirects,
423    /// Skip TLS certificate verification. See [`ConfigFile::insecure`].
424    pub insecure: bool,
425    /// Route every request through this HTTP proxy, or `None` to follow the
426    /// standard proxy environment variables. See [`ConfigFile::proxy`].
427    pub proxy: Option<String>,
428    /// Client certificate file for mutual TLS, or `None` to present none. See
429    /// [`ConfigFile::client_cert`]. Always set together with `client_key`, or
430    /// not at all — [`build_client`](crate::build_client) refuses a `Config`
431    /// where exactly one of the two is `Some`.
432    pub client_cert: Option<PathBuf>,
433    /// Private key matching `client_cert`. See [`ConfigFile::client_cert`].
434    pub client_key: Option<PathBuf>,
435    /// Persist and resend cookies automatically for this run. See
436    /// [`ConfigFile::cookie_jar`].
437    pub cookie_jar: bool,
438    /// The config files this was built from, in the order they were merged
439    /// (global first). Empty when no config file was found anywhere.
440    pub sources: Vec<PathBuf>,
441}
442
443impl Default for Config {
444    /// What Sendra does with no config file anywhere: no extra headers,
445    /// [`DEFAULT_TIMEOUT`], redirects followed up to
446    /// [`DEFAULT_MAX_REDIRECTS`] — reqwest's own default — TLS verification
447    /// on, and no explicit proxy (so the standard proxy environment
448    /// variables apply, same as any other HTTP tool).
449    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    /// Resolve configuration for a run starting from the current directory.
466    ///
467    /// The walk-up starts at the working directory rather than at the request
468    /// file's directory, so which config applies depends on where you are, the
469    /// same way `git status` does. `sendra run ../other/req.yaml` uses *this*
470    /// project's defaults, which is the reading that stays predictable when a
471    /// path is typed by hand.
472    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    /// The resolution itself, with both starting points passed in.
478    ///
479    /// [`Config::resolve`] is this with the real working directory and the real
480    /// global path. Taking them as arguments keeps the merge logic testable
481    /// against temporary directories without setting process-global environment
482    /// variables or changing the working directory, neither of which tests
483    /// running in parallel threads can do safely.
484    ///
485    /// `global_config` is the config *file*, not its directory, and neither
486    /// path needs to exist: a missing file is not an error, only an unreadable
487    /// or unparseable one is.
488    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        // Global first, then project on top of it: later files win.
496        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    /// Apply this config to `request`, returning the request as it will be
536    /// sent.
537    ///
538    /// Only the headers show up on a [`Request`]; the timeout is applied to the
539    /// client [`build_client`](crate::build_client) makes for the run. A config header is
540    /// added only when the request does not already set one with that name,
541    /// **compared case-insensitively**, because HTTP header names are
542    /// case-insensitive: a config `User-Agent` and a request `user-agent` are
543    /// the same header, and adding both would give the request two entries
544    /// under a name it never repeated itself, rather than to the stated rule.
545    ///
546    /// This is still a suppression, not a merge: `Request.headers` allowing a
547    /// name to repeat is about what the *request* is allowed to say, not an
548    /// invitation for a config default to duplicate something the request
549    /// already set. A repeated header only happens when the request file (or
550    /// a script) asks for it.
551    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
560/// Push `name: value` unless a header with that name is already present
561/// under any casing.
562///
563/// `pub(crate)` rather than private: [`Request::resolve_body`](crate::Request::resolve_body)
564/// reuses this exact rule for the `Content-Type` a structured body implies —
565/// set only when the request has not already said one itself, compared the
566/// same case-insensitive way.
567pub(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
577/// Insert `name: value`, dropping any header already present under a different
578/// casing so the same header cannot end up in the map twice.
579fn 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
584/// Walk up from `start_dir` looking for `.sendra/config.yaml`, returning the
585/// first one found.
586///
587/// This is how a command run from `crates/api/tests/` still picks up the config
588/// at the repository root — the same search git does for `.git`. The walk goes
589/// all the way to the filesystem root: stopping at a repository boundary would
590/// make Sendra behave differently inside and outside a git checkout, for a tool
591/// that otherwise has nothing to do with git.
592///
593/// Nearest wins, and only the nearest is read. A `.sendra/config.yaml` further
594/// up is not merged in as a third layer: stacking project configs would make
595/// what a directory resolves to depend on a file the reader has no particular
596/// reason to look at, and "settings for everything" is what the global config
597/// is already for.
598pub 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
605/// Path to the global config file, or `None` if the platform cannot say where
606/// config belongs (a daemon with no home directory, say) — in which case there
607/// is simply no global config.
608///
609/// `$XDG_CONFIG_HOME` is honoured first, on every platform, when it is set to
610/// an absolute path (the XDG spec says to ignore a relative one). On Linux that
611/// is exactly what [`dirs::config_dir`] already does; the explicit check
612/// extends it to macOS and Windows, where the crate returns the native location
613/// instead. That is a deliberate deviation: someone who has set
614/// `XDG_CONFIG_HOME` has said where their config lives, and the check costs
615/// nothing on Windows, where the variable is effectively never set.
616pub 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    /// Write `contents` to `path`, creating the directories above it.
631    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    /// A project root under `dir` with `.sendra/config.yaml` holding `config`.
637    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    /// A global config file under `dir` holding `config`.
644    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        // An empty directory, and a global path that does not exist: both
678        // absent is an ordinary state, not an error.
679        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        // A directory with no `.sendra` above it anywhere inside the tempdir.
698        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        // Global sets both keys; the project overrides only the timeout.
736        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        // The overridden key takes the project's value...
745        assert_eq!(config.timeout, Duration::from_secs(3));
746        // ...and the key the project said nothing about survives from global.
747        // This is the whole point: a partial project config is a patch, not a
748        // replacement.
749        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        // Overriding one header must not drop the other.
768        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        // No timeout in either file, so the hardcoded default still stands.
781        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        // One header, not two: HTTP header names are case-insensitive.
793        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        // Several levels down, the way `crates/api/tests` sits under a repo.
805        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        // And the resolved config is the same as it is from the root itself.
817        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        // Unclosed flow sequence: not valid YAML at all.
845        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        // `timeout` instead of `timeout_seconds`: a typo that would otherwise
862        // be a setting that silently never applies.
863        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    // --- `follow_redirects` -----------------------------------------------
880
881    #[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        // Unlike `headers`, there is nothing to merge one level down: the
930        // project's value replaces the global one entirely, the same way
931        // `timeout_seconds` does.
932        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    // --- `insecure` ---------------------------------------------------------
960
961    #[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    // --- `cookie_jar` ---------------------------------------------------------
1004
1005    #[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    // --- `proxy` -------------------------------------------------------------
1048
1049    #[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        // Sendra does not parse or special-case the credentials — they are
1075        // reqwest's to read out of the URL when the client is built.
1076        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    // --- `client_cert` -------------------------------------------------------
1102
1103    #[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        // Relative to `.sendra/`, the directory the config file itself is
1125        // in — not the project root, and not the process's cwd.
1126        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        // The global and project configs live under different roots in this
1139        // test; a relative `client_cert:` in the global file must resolve
1140        // against *its* directory even when a project config exists too.
1141        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        // A plain YAML scalar does not interpret `\`, so an absolute Windows
1165        // path is written as-is rather than escaped.
1166        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        // `cert`/`key` are both required inside `client_cert:` — a config
1206        // that names only one is a malformed pair, not a partial setting to
1207        // merge with the other source later. See `Config::client_cert`'s doc
1208        // comment for the case that *is* allowed: a config cert paired with a
1209        // CLI-supplied key, or vice versa.
1210        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        // It was still read, so `sources` reflects what was on disk.
1242        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        // One header, and it is the request's: sending both and letting the
1280        // HTTP client pick would make the documented rule a coin flip.
1281        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        // Repetition is a request-level choice; a config default of the same
1288        // name must still be suppressed rather than becoming a third value the
1289        // request never asked for.
1290        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        // A config default of a *different* name must not disturb a request's
1316        // own repeated header.
1317        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            // Config merges headers and nothing else; assertions are checked
1358            // against the response, which a default header cannot change, and
1359            // scripts run later still — the config has finished by then.
1360            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        // Whatever the platform root turns out to be, the tail is ours.
1405        let Some(path) = global_config_path() else {
1406            // No home directory in this environment: no global config, which
1407            // `resolve_from` already treats as an ordinary state.
1408            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}