Skip to main content

semora_cli/
config.rs

1//! Where the CLI gets its server, key and defaults.
2//!
3//! Precedence for the key: `--api-key`, then `SEMORA_API_KEY` (process env,
4//! then `.env` in this folder or a parent), then the stored login. The server:
5//! `--server`, `SEMORA_URL`, `config.toml`, then the stored default.
6
7use std::path::PathBuf;
8
9use anyhow::{Context, bail};
10use serde::Deserialize;
11use url::Url;
12
13#[derive(Debug, Default, Clone)]
14pub struct Flags {
15    pub server: Option<String>,
16    pub api_key: Option<String>,
17}
18
19/// `~/.config/semora/config.toml`
20#[derive(Debug, Default, Deserialize)]
21pub struct FileConfig {
22    pub url: Option<String>,
23    #[serde(default)]
24    pub default_tags: Vec<String>,
25    pub wait_timeout: Option<u64>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum KeySource {
30    Flag,
31    Env,
32    Stored,
33    None,
34}
35
36#[derive(Debug, Clone)]
37pub struct Settings {
38    /// Server origin, e.g. `https://semora.example.com`.
39    pub server: Option<Url>,
40    pub api_key: Option<String>,
41    pub key_source: KeySource,
42    pub default_tags: Vec<String>,
43    pub wait_timeout: u64,
44    pub config_dir: PathBuf,
45    pub use_keyring: bool,
46    pub open_browser: bool,
47}
48
49pub const DEFAULT_WAIT_TIMEOUT: u64 = 900;
50
51/// Loads the first `.env` found in the current folder or its parents.
52/// Variables already set in the process win.
53pub fn load_dotenv() {
54    let _ = dotenvy::dotenv();
55}
56
57/// `SEMORA_CONFIG_DIR`, else `~/.config/semora` (on every platform).
58pub fn config_dir(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
59    if let Some(d) = env("SEMORA_CONFIG_DIR").filter(|d| !d.is_empty()) {
60        return PathBuf::from(d);
61    }
62    let home = env("HOME")
63        .or_else(|| env("USERPROFILE"))
64        .map(PathBuf::from)
65        .unwrap_or_else(|| PathBuf::from("."));
66    home.join(".config").join("semora")
67}
68
69pub fn read_file_config(dir: &std::path::Path) -> anyhow::Result<FileConfig> {
70    let p = dir.join("config.toml");
71    match std::fs::read_to_string(&p) {
72        Ok(t) => toml::from_str(&t).with_context(|| format!("parsing {}", p.display())),
73        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
74        Err(e) => Err(e).with_context(|| format!("reading {}", p.display())),
75    }
76}
77
78/// Normalizes a server URL to its origin (no path, no trailing slash).
79pub fn parse_server(raw: &str) -> anyhow::Result<Url> {
80    let raw = raw.trim();
81    let with_scheme = if raw.contains("://") {
82        raw.to_string()
83    } else {
84        format!("https://{raw}")
85    };
86    let mut u = Url::parse(&with_scheme).with_context(|| format!("`{raw}` is not a server URL"))?;
87    if !matches!(u.scheme(), "http" | "https") {
88        bail!("server URL must be http or https");
89    }
90    u.set_path("");
91    u.set_query(None);
92    u.set_fragment(None);
93    Ok(u)
94}
95
96pub fn origin(u: &Url) -> String {
97    u.origin().ascii_serialization()
98}
99
100pub fn resolve(
101    flags: &Flags,
102    env: &dyn Fn(&str) -> Option<String>,
103    file: &FileConfig,
104    stored_default_server: Option<String>,
105    stored_key: &dyn Fn(&Url) -> Option<String>,
106) -> anyhow::Result<Settings> {
107    let nonempty = |v: Option<String>| v.filter(|s| !s.trim().is_empty());
108    let server_raw = nonempty(flags.server.clone())
109        .or_else(|| nonempty(env("SEMORA_URL")))
110        .or_else(|| nonempty(file.url.clone()))
111        .or(nonempty(stored_default_server));
112    let server = server_raw.as_deref().map(parse_server).transpose()?;
113
114    let (api_key, key_source) = if let Some(k) = nonempty(flags.api_key.clone()) {
115        (Some(k), KeySource::Flag)
116    } else if let Some(k) = nonempty(env("SEMORA_API_KEY")) {
117        (Some(k), KeySource::Env)
118    } else if let Some(k) = server.as_ref().and_then(stored_key) {
119        (Some(k), KeySource::Stored)
120    } else {
121        (None, KeySource::None)
122    };
123
124    let mut default_tags = file.default_tags.clone();
125    if let Some(t) = env("SEMORA_DEFAULT_TAGS") {
126        default_tags = t
127            .split(',')
128            .map(|s| s.trim().to_string())
129            .filter(|s| !s.is_empty())
130            .collect();
131    }
132    let wait_timeout = match env("SEMORA_WAIT_TIMEOUT") {
133        Some(v) => v
134            .trim()
135            .parse()
136            .with_context(|| format!("SEMORA_WAIT_TIMEOUT `{v}` is not a number of seconds"))?,
137        None => file.wait_timeout.unwrap_or(DEFAULT_WAIT_TIMEOUT),
138    };
139    let truthy = |k: &str| env(k).is_some_and(|v| !v.is_empty() && v != "0" && v != "false");
140    Ok(Settings {
141        server,
142        api_key,
143        key_source,
144        default_tags,
145        wait_timeout,
146        config_dir: config_dir(env),
147        use_keyring: !truthy("SEMORA_NO_KEYRING"),
148        open_browser: !truthy("SEMORA_NO_BROWSER"),
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use std::collections::HashMap;
156
157    fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
158        let m: HashMap<String, String> = pairs
159            .iter()
160            .map(|(k, v)| (k.to_string(), v.to_string()))
161            .collect();
162        move |k| m.get(k).cloned()
163    }
164
165    #[test]
166    fn key_precedence_flag_env_stored() {
167        let stored = |_: &Url| Some("sm_stored".to_string());
168        let env = env_of(&[
169            ("SEMORA_URL", "https://semora.example.com/"),
170            ("SEMORA_API_KEY", "sm_env"),
171        ]);
172        let flags = Flags {
173            api_key: Some("sm_flag".into()),
174            ..Default::default()
175        };
176        let s = resolve(&flags, &env, &FileConfig::default(), None, &stored).unwrap();
177        assert_eq!(
178            (s.api_key.as_deref(), s.key_source),
179            (Some("sm_flag"), KeySource::Flag)
180        );
181        let s = resolve(
182            &Flags::default(),
183            &env,
184            &FileConfig::default(),
185            None,
186            &stored,
187        )
188        .unwrap();
189        assert_eq!(
190            (s.api_key.as_deref(), s.key_source),
191            (Some("sm_env"), KeySource::Env)
192        );
193        let env = env_of(&[("SEMORA_URL", "https://semora.example.com")]);
194        let s = resolve(
195            &Flags::default(),
196            &env,
197            &FileConfig::default(),
198            None,
199            &stored,
200        )
201        .unwrap();
202        assert_eq!(
203            (s.api_key.as_deref(), s.key_source),
204            (Some("sm_stored"), KeySource::Stored)
205        );
206    }
207
208    #[test]
209    fn server_precedence_and_normalization() {
210        let none = |_: &Url| None;
211        let file = FileConfig {
212            url: Some("https://file.example.com".into()),
213            ..Default::default()
214        };
215        let s = resolve(
216            &Flags::default(),
217            &env_of(&[]),
218            &file,
219            Some("https://stored.example".into()),
220            &none,
221        )
222        .unwrap();
223        assert_eq!(
224            origin(s.server.as_ref().unwrap()),
225            "https://file.example.com"
226        );
227        let s = resolve(
228            &Flags::default(),
229            &env_of(&[]),
230            &FileConfig::default(),
231            Some("https://stored.example".into()),
232            &none,
233        )
234        .unwrap();
235        assert_eq!(origin(s.server.as_ref().unwrap()), "https://stored.example");
236        let flags = Flags {
237            server: Some("semora.example.com/d/abc/".into()),
238            ..Default::default()
239        };
240        let s = resolve(
241            &flags,
242            &env_of(&[("SEMORA_URL", "http://x")]),
243            &file,
244            None,
245            &none,
246        )
247        .unwrap();
248        assert_eq!(
249            origin(s.server.as_ref().unwrap()),
250            "https://semora.example.com"
251        );
252    }
253
254    #[test]
255    fn default_tags_and_timeout() {
256        let none = |_: &Url| None;
257        let env = env_of(&[
258            ("SEMORA_DEFAULT_TAGS", "proj, docs ,"),
259            ("SEMORA_WAIT_TIMEOUT", "30"),
260        ]);
261        let s = resolve(&Flags::default(), &env, &FileConfig::default(), None, &none).unwrap();
262        assert_eq!(s.default_tags, vec!["proj", "docs"]);
263        assert_eq!(s.wait_timeout, 30);
264        let bad = env_of(&[("SEMORA_WAIT_TIMEOUT", "soon")]);
265        assert!(resolve(&Flags::default(), &bad, &FileConfig::default(), None, &none).is_err());
266    }
267
268    #[test]
269    fn config_dir_override() {
270        assert_eq!(
271            config_dir(&env_of(&[("SEMORA_CONFIG_DIR", "/tmp/x")])),
272            PathBuf::from("/tmp/x")
273        );
274        assert_eq!(
275            config_dir(&env_of(&[("HOME", "/home/a")])),
276            PathBuf::from("/home/a/.config/semora")
277        );
278    }
279}