semora-cli 0.8.0

Semora CLI: publish Markdown and HTML, and hear back from the page
Documentation
//! Where the CLI gets its server, key and defaults.
//!
//! Precedence for the key: `--api-key`, then `SEMORA_API_KEY` (process env,
//! then `.env` in this folder or a parent), then the stored login. The server:
//! `--server`, `SEMORA_URL`, `config.toml`, then the stored default.

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>,
}

/// `~/.config/semora/config.toml`
#[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 {
    /// Server origin, e.g. `https://semora.example.com`.
    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;

/// Loads the first `.env` found in the current folder or its parents.
/// Variables already set in the process win.
pub fn load_dotenv() {
    let _ = dotenvy::dotenv();
}

/// `SEMORA_CONFIG_DIR`, else `~/.config/semora` (on every platform).
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())),
    }
}

/// Normalizes a server URL to its origin (no path, no trailing slash).
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")
        );
    }
}