Skip to main content

singularity_cli/
config.rs

1use std::fs;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6
7const ENV_VAR: &str = "SINGULARITY_TOKEN";
8
9#[derive(Debug, Serialize, Deserialize, Default)]
10pub struct Config {
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub token: Option<String>,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub timezone: Option<String>,
15}
16
17pub fn config_path() -> Result<PathBuf> {
18    let config_dir = dirs::config_dir().context("could not determine config directory")?;
19    Ok(config_dir.join("singularity").join("config.toml"))
20}
21
22pub fn load_config() -> Result<Config> {
23    let path = config_path()?;
24    match fs::read_to_string(&path) {
25        Ok(content) => {
26            let config: Config = toml::from_str(&content).context("invalid config file")?;
27            Ok(config)
28        }
29        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
30        Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
31    }
32}
33
34pub fn save_config(config: &Config) -> Result<()> {
35    let path = config_path()?;
36    if let Some(parent) = path.parent() {
37        fs::create_dir_all(parent)
38            .with_context(|| format!("failed to create {}", parent.display()))?;
39    }
40    let content = toml::to_string_pretty(config).context("failed to serialize config")?;
41    fs::write(&path, content).with_context(|| format!("failed to write {}", path.display()))?;
42    Ok(())
43}
44
45pub fn resolve_token() -> Result<String> {
46    if let Ok(token) = std::env::var(ENV_VAR)
47        && !token.is_empty()
48    {
49        return Ok(token);
50    }
51
52    let config = load_config()?;
53    if let Some(token) = config.token
54        && !token.is_empty()
55    {
56        return Ok(token);
57    }
58
59    bail!(
60        "no API token found. Set {} env var or run: singularity config set-token <TOKEN>",
61        ENV_VAR
62    )
63}
64
65pub fn set_token(token: &str) -> Result<()> {
66    let mut config = load_config()?;
67    config.token = Some(token.to_string());
68    save_config(&config)?;
69    let path = config_path()?;
70    println!("Token saved to {}", path.display());
71    Ok(())
72}
73
74pub fn set_timezone(timezone: &str) -> Result<()> {
75    let mut config = load_config()?;
76    config.timezone = Some(timezone.to_string());
77    save_config(&config)?;
78    let path = config_path()?;
79    println!("Timezone saved to {}", path.display());
80    Ok(())
81}
82
83pub fn resolve_token_and_timezone() -> Result<(String, Option<chrono_tz::Tz>)> {
84    let env_token = std::env::var(ENV_VAR).ok().filter(|t| !t.is_empty());
85    let config = load_config()?;
86
87    let token = match env_token {
88        Some(t) => t,
89        None => config.token.filter(|t| !t.is_empty()).ok_or_else(|| {
90            anyhow::anyhow!(
91                "no API token found. Set {} env var or run: singularity config set-token <TOKEN>",
92                ENV_VAR
93            )
94        })?,
95    };
96
97    let tz = config.timezone.and_then(|s| {
98        s.parse::<chrono_tz::Tz>()
99            .map_err(|_| eprintln!("warning: invalid timezone '{}' in config, using UTC", s))
100            .ok()
101    });
102
103    Ok((token, tz))
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn resolve_token_from_env_var() {
112        let key = "SINGULARITY_TOKEN_TEST_1";
113        unsafe { std::env::set_var(key, "my-secret") };
114        let val = std::env::var(key).unwrap();
115        assert_eq!(val, "my-secret");
116        unsafe { std::env::remove_var(key) };
117    }
118
119    #[test]
120    fn resolve_token_reads_toml() {
121        let content = "token = \"from-file\"\n";
122        let config: Config = toml::from_str(content).unwrap();
123        assert_eq!(config.token.as_deref(), Some("from-file"));
124    }
125
126    #[test]
127    fn resolve_token_empty_toml_yields_none() {
128        let content = "token = \"\"\n";
129        let config: Config = toml::from_str(content).unwrap();
130        assert!(config.token.as_deref().unwrap().is_empty());
131    }
132
133    #[test]
134    fn config_path_is_under_config_dir() {
135        let path = config_path().unwrap();
136        assert!(path.ends_with("singularity/config.toml"));
137    }
138
139    #[test]
140    fn config_roundtrip_with_timezone() {
141        let config: Config =
142            toml::from_str("token = \"abc\"\ntimezone = \"Europe/Kyiv\"\n").unwrap();
143        assert_eq!(config.token.as_deref(), Some("abc"));
144        assert_eq!(config.timezone.as_deref(), Some("Europe/Kyiv"));
145        let serialized = toml::to_string_pretty(&config).unwrap();
146        assert!(serialized.contains("token"));
147        assert!(serialized.contains("timezone"));
148    }
149
150    #[test]
151    fn config_default_has_no_fields() {
152        let config = Config::default();
153        assert!(config.token.is_none());
154        assert!(config.timezone.is_none());
155    }
156
157    #[test]
158    fn config_deserialize_token_only() {
159        let config: Config = toml::from_str("token = \"abc\"\n").unwrap();
160        assert_eq!(config.token.as_deref(), Some("abc"));
161        assert!(config.timezone.is_none());
162    }
163}