1use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use figment::{
9 Figment,
10 providers::{Env, Format, Serialized, Toml},
11};
12use serde::{Deserialize, Serialize};
13
14pub mod creds;
15
16pub const DEFAULT_API_URL: &str = "https://api.harmont.dev";
17
18#[derive(
27 Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, derive_more::Display,
28)]
29#[serde(rename_all = "lowercase")]
30pub enum Backend {
31 #[default]
32 #[display("docker")]
33 Docker,
34 #[display("cloud")]
35 Cloud,
36}
37
38#[must_use]
53pub fn app_url(api: &str, override_url: Option<&str>) -> String {
54 if let Some(u) = override_url.map(str::trim).filter(|u| !u.is_empty()) {
55 return u.trim_end_matches('/').to_string();
56 }
57 let api = api.trim_end_matches('/');
58 if let Some(rest) = api.strip_prefix("https://api.") {
59 return format!("https://app.{rest}");
60 }
61 if let Some(rest) = api.strip_prefix("http://api.") {
62 return format!("http://app.{rest}");
63 }
64 api.to_string()
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
68#[non_exhaustive]
69pub struct CloudConfig {
70 pub org: Option<String>,
71 pub api_url: String,
72}
73
74impl Default for CloudConfig {
75 fn default() -> Self {
76 Self {
77 org: None,
78 api_url: DEFAULT_API_URL.to_owned(),
79 }
80 }
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
84#[non_exhaustive]
85pub struct Preferences {
86 pub format: String,
87 pub auto_watch: bool,
88}
89
90impl Default for Preferences {
91 fn default() -> Self {
92 Self {
93 format: "human".to_owned(),
94 auto_watch: false,
95 }
96 }
97}
98
99#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[non_exhaustive]
101pub struct Config {
102 #[serde(default)]
103 pub backend: Backend,
104 #[serde(default)]
105 pub cloud: CloudConfig,
106 #[serde(default)]
107 pub preferences: Preferences,
108}
109
110impl Config {
111 pub fn user_config_path() -> Result<PathBuf> {
117 let dir = hm_util::dirs::hm_config_dir().context("could not determine config directory")?;
118 Ok(dir.join("config.toml"))
119 }
120
121 #[must_use]
123 pub fn project_config_path(project_root: &Path) -> PathBuf {
124 project_root.join(".hm").join("config.toml")
125 }
126
127 pub fn load(project_root: Option<&Path>) -> Result<Self> {
134 let user_path = Self::user_config_path()?;
135 let project_path = project_root.map(Self::project_config_path);
136 Self::load_from_paths(Some(&user_path), project_path.as_deref())
137 .context("loading configuration")
138 }
139
140 pub fn load_from_paths(user_path: Option<&Path>, project_path: Option<&Path>) -> Result<Self> {
154 let mut figment = Figment::new().merge(Serialized::defaults(Self::default()));
155
156 if let Some(p) = user_path {
157 figment = figment.merge(Toml::file(p));
158 }
159 if let Some(p) = project_path {
160 figment = figment.merge(Toml::file(p));
161 }
162
163 figment = figment
164 .merge(Env::prefixed("HM_").split("__"))
165 .merge(hm_alias_env());
166
167 Ok(figment.extract()?)
168 }
169
170 pub fn save_to(&self, path: &Path) -> Result<()> {
176 let serialized = toml::to_string_pretty(self).context("serializing config")?;
177 hm_util::os::fs::blocking::write_atomic_restricted(
178 path,
179 serialized.as_bytes(),
180 hm_util::os::fs::FileMode(0o644),
181 hm_util::os::fs::DirMode(0o700),
182 )
183 .with_context(|| format!("writing {}", path.display()))
184 }
185
186 pub fn save_user(&self) -> Result<()> {
192 self.save_to(&Self::user_config_path()?)
193 }
194}
195
196fn hm_alias_env() -> Env {
204 Env::raw()
205 .only(&["HM_ORG", "HM_API_URL"])
206 .map(|key| match key.as_str() {
207 "HM_ORG" => "cloud.org".into(),
208 "HM_API_URL" => "cloud.api_url".into(),
209 other => other.into(),
210 })
211 .split(".")
212}
213
214#[cfg(test)]
215#[allow(clippy::unwrap_used)]
216mod tests {
217 use super::*;
218 use std::io::Write as _;
219 use std::sync::{Mutex, MutexGuard};
220
221 static ENV_LOCK: Mutex<()> = Mutex::new(());
229
230 fn env_guard() -> MutexGuard<'static, ()> {
231 ENV_LOCK
232 .lock()
233 .unwrap_or_else(std::sync::PoisonError::into_inner)
234 }
235
236 #[test]
237 fn app_url_maps_prod_api_to_app() {
238 assert_eq!(app_url(DEFAULT_API_URL, None), "https://app.harmont.dev");
239 }
240
241 #[test]
242 fn app_url_override_wins_and_trims_trailing_slash() {
243 assert_eq!(
244 app_url(DEFAULT_API_URL, Some("http://localhost:5173/")),
245 "http://localhost:5173"
246 );
247 }
248
249 #[test]
250 fn app_url_empty_override_is_ignored() {
251 assert_eq!(
252 app_url(DEFAULT_API_URL, Some(" ")),
253 "https://app.harmont.dev"
254 );
255 }
256
257 #[test]
258 fn app_url_falls_back_to_api_for_unmapped_host() {
259 assert_eq!(
260 app_url("http://localhost:4000", None),
261 "http://localhost:4000"
262 );
263 assert_eq!(app_url("http://api.dev.test/", None), "http://app.dev.test");
265 }
266
267 #[test]
268 fn default_config_values() {
269 let cfg = Config::default();
270 assert_eq!(cfg.backend, Backend::Docker);
271 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
272 assert!(cfg.cloud.org.is_none());
273 assert_eq!(cfg.preferences.format, "human");
274 assert!(!cfg.preferences.auto_watch);
275 }
276
277 #[test]
278 fn deserialize_full_toml() {
279 let toml_str = r#"
280[cloud]
281org = "acme"
282api_url = "https://custom.api"
283
284[preferences]
285format = "json"
286auto_watch = true
287"#;
288 let cfg: Config = toml::from_str(toml_str).unwrap();
289 assert_eq!(cfg.cloud.org.as_deref(), Some("acme"));
290 assert_eq!(cfg.cloud.api_url, "https://custom.api");
291 assert_eq!(cfg.preferences.format, "json");
292 assert!(cfg.preferences.auto_watch);
293 }
294
295 #[test]
296 fn deserialize_sparse_toml() {
297 let _g = env_guard();
298 let toml_str = r#"
299[cloud]
300org = "sparse-co"
301"#;
302 let mut f = tempfile::NamedTempFile::new().unwrap();
303 f.write_all(toml_str.as_bytes()).unwrap();
304
305 let cfg = Config::load_from_paths(Some(f.path()), None).unwrap();
306 assert_eq!(cfg.cloud.org.as_deref(), Some("sparse-co"));
307 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
308 assert_eq!(cfg.preferences.format, "human");
309 assert!(!cfg.preferences.auto_watch);
310 }
311
312 #[test]
313 fn deserialize_empty_toml() {
314 let _g = env_guard();
315 let mut f = tempfile::NamedTempFile::new().unwrap();
316 f.write_all(b"").unwrap();
317
318 let cfg = Config::load_from_paths(Some(f.path()), None).unwrap();
319 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
320 assert!(cfg.cloud.org.is_none());
321 assert_eq!(cfg.preferences.format, "human");
322 assert!(!cfg.preferences.auto_watch);
323 }
324
325 #[test]
326 fn figment_project_overrides_user() {
327 let _g = env_guard();
328 let user_toml = r#"
329[cloud]
330org = "user-org"
331api_url = "https://user.api"
332
333[preferences]
334format = "json"
335"#;
336 let project_toml = r#"
337[cloud]
338org = "project-org"
339"#;
340
341 let mut user_file = tempfile::NamedTempFile::new().unwrap();
342 user_file.write_all(user_toml.as_bytes()).unwrap();
343
344 let mut project_file = tempfile::NamedTempFile::new().unwrap();
345 project_file.write_all(project_toml.as_bytes()).unwrap();
346
347 let cfg =
348 Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap();
349
350 assert_eq!(cfg.cloud.org.as_deref(), Some("project-org"));
351 assert_eq!(cfg.cloud.api_url, "https://user.api");
352 assert_eq!(cfg.preferences.format, "json");
353 }
354
355 #[test]
356 fn backend_display_matches_wire_strings() {
357 assert_eq!(Backend::Docker.to_string(), "docker");
358 assert_eq!(Backend::Cloud.to_string(), "cloud");
359 }
360
361 #[test]
362 fn backend_defaults_docker_and_parses_and_layers() {
363 let _g = env_guard();
364 assert_eq!(Config::default().backend, Backend::Docker);
366
367 let mut user_file = tempfile::NamedTempFile::new().unwrap();
369 user_file.write_all(br#"backend = "cloud""#).unwrap();
370
371 let mut project_file = tempfile::NamedTempFile::new().unwrap();
372 project_file.write_all(br#"backend = "docker""#).unwrap();
373
374 let cfg =
375 Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap();
376 assert_eq!(cfg.backend, Backend::Docker);
377
378 let cfg_user = Config::load_from_paths(Some(user_file.path()), None).unwrap();
380 assert_eq!(cfg_user.backend, Backend::Cloud);
381 }
382
383 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
384 async fn save_and_reload_roundtrip() {
385 let _g = env_guard();
386 let tmp = tempfile::tempdir().unwrap();
387 let path = tmp.path().join("config.toml");
388 let cfg = Config {
389 cloud: CloudConfig {
390 org: Some("saved-org".into()),
391 ..CloudConfig::default()
392 },
393 ..Config::default()
394 };
395 cfg.save_to(&path).unwrap();
396
397 let loaded = Config::load_from_paths(Some(&path), None).unwrap();
398 assert_eq!(loaded.cloud.org.as_deref(), Some("saved-org"));
399 assert_eq!(loaded.cloud.api_url, DEFAULT_API_URL);
400 assert_eq!(loaded.preferences.format, "human");
401 }
402
403 #[test]
404 #[allow(clippy::result_large_err)] fn hm_env_overrides_cloud_keys() {
406 let _g = env_guard();
407 figment::Jail::expect_with(|jail| {
409 jail.set_env("HM_ORG", "env-org");
410 jail.set_env("HM_API_URL", "https://env.api");
411
412 let cfg = Config::load_from_paths(None, None).unwrap();
413 assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
414 assert_eq!(cfg.cloud.api_url, "https://env.api");
415 Ok(())
416 });
417 }
418
419 #[test]
420 #[allow(clippy::result_large_err)] fn hm_env_overrides_user_file() {
422 let _g = env_guard();
423 figment::Jail::expect_with(|jail| {
425 jail.set_env("HM_ORG", "env-org");
426
427 jail.create_file(
428 "config.toml",
429 "[cloud]\norg = \"file-org\"\napi_url = \"https://file.api\"\n",
430 )?;
431 let user = jail.directory().join("config.toml");
432
433 let cfg = Config::load_from_paths(Some(&user), None).unwrap();
434 assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
435 assert_eq!(cfg.cloud.api_url, "https://file.api");
437 Ok(())
438 });
439 }
440
441 #[test]
442 fn figment_missing_files_still_resolve() {
443 let _g = env_guard();
444 let nonexistent_user = Path::new("/tmp/harmont-test-nonexistent-user/config.toml");
445 let nonexistent_project = Path::new("/tmp/harmont-test-nonexistent-project/config.toml");
446
447 let cfg =
448 Config::load_from_paths(Some(nonexistent_user), Some(nonexistent_project)).unwrap();
449
450 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
451 assert!(cfg.cloud.org.is_none());
452 assert_eq!(cfg.preferences.format, "human");
453 assert!(!cfg.preferences.auto_watch);
454 }
455}