1use 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#[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 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
51pub fn load_dotenv() {
54 let _ = dotenvy::dotenv();
55}
56
57pub 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
78pub 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}