singularity_cli/
config.rs1use 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}