use std::path::PathBuf;
use anyhow::{Context, bail};
use serde::Deserialize;
use url::Url;
#[derive(Debug, Default, Clone)]
pub struct Flags {
pub server: Option<String>,
pub api_key: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct FileConfig {
pub url: Option<String>,
#[serde(default)]
pub default_tags: Vec<String>,
pub wait_timeout: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
Flag,
Env,
Stored,
None,
}
#[derive(Debug, Clone)]
pub struct Settings {
pub server: Option<Url>,
pub api_key: Option<String>,
pub key_source: KeySource,
pub default_tags: Vec<String>,
pub wait_timeout: u64,
pub config_dir: PathBuf,
pub use_keyring: bool,
pub open_browser: bool,
}
pub const DEFAULT_WAIT_TIMEOUT: u64 = 900;
pub fn load_dotenv() {
let _ = dotenvy::dotenv();
}
pub fn config_dir(env: &dyn Fn(&str) -> Option<String>) -> PathBuf {
if let Some(d) = env("SEMORA_CONFIG_DIR").filter(|d| !d.is_empty()) {
return PathBuf::from(d);
}
let home = env("HOME")
.or_else(|| env("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".config").join("semora")
}
pub fn read_file_config(dir: &std::path::Path) -> anyhow::Result<FileConfig> {
let p = dir.join("config.toml");
match std::fs::read_to_string(&p) {
Ok(t) => toml::from_str(&t).with_context(|| format!("parsing {}", p.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(FileConfig::default()),
Err(e) => Err(e).with_context(|| format!("reading {}", p.display())),
}
}
pub fn parse_server(raw: &str) -> anyhow::Result<Url> {
let raw = raw.trim();
let with_scheme = if raw.contains("://") {
raw.to_string()
} else {
format!("https://{raw}")
};
let mut u = Url::parse(&with_scheme).with_context(|| format!("`{raw}` is not a server URL"))?;
if !matches!(u.scheme(), "http" | "https") {
bail!("server URL must be http or https");
}
u.set_path("");
u.set_query(None);
u.set_fragment(None);
Ok(u)
}
pub fn origin(u: &Url) -> String {
u.origin().ascii_serialization()
}
pub fn resolve(
flags: &Flags,
env: &dyn Fn(&str) -> Option<String>,
file: &FileConfig,
stored_default_server: Option<String>,
stored_key: &dyn Fn(&Url) -> Option<String>,
) -> anyhow::Result<Settings> {
let nonempty = |v: Option<String>| v.filter(|s| !s.trim().is_empty());
let server_raw = nonempty(flags.server.clone())
.or_else(|| nonempty(env("SEMORA_URL")))
.or_else(|| nonempty(file.url.clone()))
.or(nonempty(stored_default_server));
let server = server_raw.as_deref().map(parse_server).transpose()?;
let (api_key, key_source) = if let Some(k) = nonempty(flags.api_key.clone()) {
(Some(k), KeySource::Flag)
} else if let Some(k) = nonempty(env("SEMORA_API_KEY")) {
(Some(k), KeySource::Env)
} else if let Some(k) = server.as_ref().and_then(stored_key) {
(Some(k), KeySource::Stored)
} else {
(None, KeySource::None)
};
let mut default_tags = file.default_tags.clone();
if let Some(t) = env("SEMORA_DEFAULT_TAGS") {
default_tags = t
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
let wait_timeout = match env("SEMORA_WAIT_TIMEOUT") {
Some(v) => v
.trim()
.parse()
.with_context(|| format!("SEMORA_WAIT_TIMEOUT `{v}` is not a number of seconds"))?,
None => file.wait_timeout.unwrap_or(DEFAULT_WAIT_TIMEOUT),
};
let truthy = |k: &str| env(k).is_some_and(|v| !v.is_empty() && v != "0" && v != "false");
Ok(Settings {
server,
api_key,
key_source,
default_tags,
wait_timeout,
config_dir: config_dir(env),
use_keyring: !truthy("SEMORA_NO_KEYRING"),
open_browser: !truthy("SEMORA_NO_BROWSER"),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
let m: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |k| m.get(k).cloned()
}
#[test]
fn key_precedence_flag_env_stored() {
let stored = |_: &Url| Some("sm_stored".to_string());
let env = env_of(&[
("SEMORA_URL", "https://semora.example.com/"),
("SEMORA_API_KEY", "sm_env"),
]);
let flags = Flags {
api_key: Some("sm_flag".into()),
..Default::default()
};
let s = resolve(&flags, &env, &FileConfig::default(), None, &stored).unwrap();
assert_eq!(
(s.api_key.as_deref(), s.key_source),
(Some("sm_flag"), KeySource::Flag)
);
let s = resolve(
&Flags::default(),
&env,
&FileConfig::default(),
None,
&stored,
)
.unwrap();
assert_eq!(
(s.api_key.as_deref(), s.key_source),
(Some("sm_env"), KeySource::Env)
);
let env = env_of(&[("SEMORA_URL", "https://semora.example.com")]);
let s = resolve(
&Flags::default(),
&env,
&FileConfig::default(),
None,
&stored,
)
.unwrap();
assert_eq!(
(s.api_key.as_deref(), s.key_source),
(Some("sm_stored"), KeySource::Stored)
);
}
#[test]
fn server_precedence_and_normalization() {
let none = |_: &Url| None;
let file = FileConfig {
url: Some("https://file.example.com".into()),
..Default::default()
};
let s = resolve(
&Flags::default(),
&env_of(&[]),
&file,
Some("https://stored.example".into()),
&none,
)
.unwrap();
assert_eq!(
origin(s.server.as_ref().unwrap()),
"https://file.example.com"
);
let s = resolve(
&Flags::default(),
&env_of(&[]),
&FileConfig::default(),
Some("https://stored.example".into()),
&none,
)
.unwrap();
assert_eq!(origin(s.server.as_ref().unwrap()), "https://stored.example");
let flags = Flags {
server: Some("semora.example.com/d/abc/".into()),
..Default::default()
};
let s = resolve(
&flags,
&env_of(&[("SEMORA_URL", "http://x")]),
&file,
None,
&none,
)
.unwrap();
assert_eq!(
origin(s.server.as_ref().unwrap()),
"https://semora.example.com"
);
}
#[test]
fn default_tags_and_timeout() {
let none = |_: &Url| None;
let env = env_of(&[
("SEMORA_DEFAULT_TAGS", "proj, docs ,"),
("SEMORA_WAIT_TIMEOUT", "30"),
]);
let s = resolve(&Flags::default(), &env, &FileConfig::default(), None, &none).unwrap();
assert_eq!(s.default_tags, vec!["proj", "docs"]);
assert_eq!(s.wait_timeout, 30);
let bad = env_of(&[("SEMORA_WAIT_TIMEOUT", "soon")]);
assert!(resolve(&Flags::default(), &bad, &FileConfig::default(), None, &none).is_err());
}
#[test]
fn config_dir_override() {
assert_eq!(
config_dir(&env_of(&[("SEMORA_CONFIG_DIR", "/tmp/x")])),
PathBuf::from("/tmp/x")
);
assert_eq!(
config_dir(&env_of(&[("HOME", "/home/a")])),
PathBuf::from("/home/a/.config/semora")
);
}
}