Skip to main content

dsp_cli/config/
mod.rs

1//! Config resolution — layer 5 of ADR-0008.
2//!
3//! The four-layer stack from ADR-0007: flag → env var → `.env` (CWD) →
4//! fail. `.env` is loaded via `dotenvy::dotenv()` at startup; no
5//! hard-coded default server.
6//!
7//! By the time [`Config::resolve`] is called, the chain has already collapsed
8//! to a single `Option<&str>`: clap's `env = "DSP_SERVER"` attribute merged
9//! the flag and env-var tiers; `dotenvy::dotenv()` in `main` populated the
10//! env from `.env` before clap ran. This function handles only the final
11//! two cases: expand a shortcut or literal, or fail.
12
13pub mod auth_cache;
14pub use auth_cache::AuthCache;
15
16pub mod token;
17pub use token::{ResolvedToken, TokenOrigin, resolve_token};
18
19use crate::diagnostic::Diagnostic;
20
21/// Built-in shortcut names → canonical server URLs. See ADR-0007.
22///
23/// Lookup is linear; a handful of entries is too small to justify a `HashMap`.
24/// Matching is case-insensitive (`PROD`, `Prod`, `prod` all resolve); a value
25/// that matches no shortcut passes through unchanged as a literal URL.
26///
27/// Reachability of each host was last verified 2026-05-27 via `GET /health`.
28const SHORTCUTS: &[(&str, &str)] = &[
29    ("prod", "https://api.dasch.swiss"),
30    ("stage", "https://api.stage.dasch.swiss"),
31    ("dev", "https://api.dev.dasch.swiss"),
32    ("demo", "https://api.demo.dasch.swiss"),
33    ("rdu", "https://api.rdu.dasch.swiss"),
34    ("ls-prod", "https://api.ls-prod-server.dasch.swiss"),
35    ("ls-test", "https://api.ls-test-server.dasch.swiss"),
36    ("local", "http://0.0.0.0:3333"),
37];
38
39/// Resolved configuration for a single command invocation.
40#[derive(Debug, Clone)]
41pub struct Config {
42    /// The fully-resolved server URL (or shortcut-expanded URL).
43    pub server: String,
44}
45
46impl Config {
47    /// Resolve the active server from the collapsed `Option<&str>`.
48    ///
49    /// `server` is the value after clap has merged `--server` and
50    /// `DSP_SERVER` (including any `.env` values dotenvy loaded at startup).
51    /// `None` means the user provided nothing — that's a usage error.
52    pub fn resolve(server: Option<&str>) -> Result<Self, Diagnostic> {
53        match server {
54            None => Err(Diagnostic::Usage(
55                "no server specified. Provide one via --server <prod|dev|…|URL>, \
56the DSP_SERVER environment variable, or a .env file in the current directory. \
57See `dsp docs connecting` for details."
58                    .to_string(),
59            )),
60            Some(s) => {
61                // Case-insensitive shortcut match; lowercase only for the lookup
62                // so a literal URL passes through with its original casing intact.
63                let lower = s.to_ascii_lowercase();
64                let url = SHORTCUTS
65                    .iter()
66                    .find(|(name, _)| *name == lower)
67                    .map(|(_, url)| *url)
68                    .unwrap_or(s);
69
70                tracing::debug!(server = url, "resolved server");
71
72                Ok(Config {
73                    server: url.to_string(),
74                })
75            }
76        }
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::diagnostic::Diagnostic;
84
85    #[test]
86    fn resolve_with_literal_url() {
87        let cfg = Config::resolve(Some("https://api.example.org")).unwrap();
88        assert_eq!(cfg.server, "https://api.example.org");
89    }
90
91    #[test]
92    fn resolve_with_known_shortcut_prod() {
93        let cfg = Config::resolve(Some("prod")).unwrap();
94        assert_eq!(cfg.server, "https://api.dasch.swiss");
95    }
96
97    #[test]
98    fn resolve_with_known_shortcut_local() {
99        let cfg = Config::resolve(Some("local")).unwrap();
100        assert_eq!(cfg.server, "http://0.0.0.0:3333");
101    }
102
103    #[test]
104    fn resolve_with_unknown_word_passes_through() {
105        let cfg = Config::resolve(Some("staging-experiment")).unwrap();
106        assert_eq!(cfg.server, "staging-experiment");
107    }
108
109    #[test]
110    fn resolve_with_known_shortcut_dev() {
111        let cfg = Config::resolve(Some("dev")).unwrap();
112        assert_eq!(cfg.server, "https://api.dev.dasch.swiss");
113    }
114
115    #[test]
116    fn resolve_with_known_shortcut_demo() {
117        let cfg = Config::resolve(Some("demo")).unwrap();
118        assert_eq!(cfg.server, "https://api.demo.dasch.swiss");
119    }
120
121    #[test]
122    fn resolve_shortcut_is_case_insensitive() {
123        // Mixed and upper case both resolve to the canonical URL.
124        assert_eq!(
125            Config::resolve(Some("PROD")).unwrap().server,
126            "https://api.dasch.swiss"
127        );
128        assert_eq!(
129            Config::resolve(Some("Dev")).unwrap().server,
130            "https://api.dev.dasch.swiss"
131        );
132    }
133
134    #[test]
135    fn resolve_literal_url_preserves_case() {
136        // A non-shortcut value passes through unchanged — casing is NOT lowered.
137        let cfg = Config::resolve(Some("https://API.Example.ORG/Path")).unwrap();
138        assert_eq!(cfg.server, "https://API.Example.ORG/Path");
139    }
140
141    #[test]
142    fn resolve_with_none_returns_usage_diagnostic() {
143        let err = Config::resolve(None).unwrap_err();
144        assert!(matches!(err, Diagnostic::Usage(_)));
145    }
146
147    #[test]
148    fn missing_server_message_mentions_all_three_paths() {
149        let err = Config::resolve(None).unwrap_err();
150        let msg = err.to_string();
151        assert!(msg.contains("--server"), "missing --server in: {msg}");
152        assert!(msg.contains("DSP_SERVER"), "missing DSP_SERVER in: {msg}");
153        assert!(msg.contains(".env"), "missing .env in: {msg}");
154    }
155
156    #[test]
157    fn shortcut_and_canonical_url_resolve_identically() {
158        // set_entry(server, …) and token(server) both use the resolved URL as the map
159        // key, so "dev" and its canonical expansion must produce the same string.
160        let via_shortcut = Config::resolve(Some("dev")).unwrap();
161        let via_url = Config::resolve(Some("https://api.dev.dasch.swiss")).unwrap();
162        assert_eq!(
163            via_shortcut.server, via_url.server,
164            "shortcut 'dev' and its URL must resolve to the same string for \
165cache key lookups to work"
166        );
167    }
168}