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 pub pipeline: Option<String>,
76}
77
78impl Default for CloudConfig {
79 fn default() -> Self {
80 Self {
81 org: None,
82 api_url: DEFAULT_API_URL.to_owned(),
83 pipeline: None,
84 }
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89#[non_exhaustive]
90pub struct Preferences {
91 pub format: String,
92 pub auto_watch: bool,
93}
94
95impl Default for Preferences {
96 fn default() -> Self {
97 Self {
98 format: "human".to_owned(),
99 auto_watch: false,
100 }
101 }
102}
103
104#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[non_exhaustive]
106pub struct Config {
107 #[serde(default)]
108 pub backend: Backend,
109 #[serde(default)]
110 pub cloud: CloudConfig,
111 #[serde(default)]
112 pub preferences: Preferences,
113}
114
115impl Config {
116 pub fn user_config_path() -> Result<PathBuf> {
122 let dir = hm_util::dirs::hm_config_dir().context("could not determine config directory")?;
123 Ok(dir.join("config.toml"))
124 }
125
126 #[must_use]
128 pub fn project_config_path(project_root: &Path) -> PathBuf {
129 project_root.join(".hm").join("config.toml")
130 }
131
132 pub fn load(project_root: Option<&Path>) -> Result<Self> {
139 let user_path = Self::user_config_path()?;
140 let project_path = project_root.map(Self::project_config_path);
141 Self::load_from_paths(Some(&user_path), project_path.as_deref())
142 .context("loading configuration")
143 }
144
145 pub fn load_from_paths(user_path: Option<&Path>, project_path: Option<&Path>) -> Result<Self> {
159 let mut figment = Figment::new().merge(Serialized::defaults(Self::default()));
160
161 if let Some(p) = user_path {
162 figment = figment.merge(Toml::file(p));
163 }
164 if let Some(p) = project_path {
165 figment = figment.merge(Toml::file(p));
166 }
167
168 figment = figment
169 .merge(Env::prefixed("HM_").split("__"))
170 .merge(hm_alias_env());
171
172 Ok(figment.extract()?)
173 }
174
175 pub fn save_to(&self, path: &Path) -> Result<()> {
181 let serialized = toml::to_string_pretty(self).context("serializing config")?;
182 hm_util::os::fs::blocking::write_atomic_restricted(
183 path,
184 serialized.as_bytes(),
185 hm_util::os::fs::FileMode(0o644),
186 hm_util::os::fs::DirMode(0o700),
187 )
188 .with_context(|| format!("writing {}", path.display()))
189 }
190
191 pub fn save_user(&self) -> Result<()> {
197 self.save_to(&Self::user_config_path()?)
198 }
199}
200
201fn hm_alias_env() -> Env {
209 Env::raw()
210 .only(&["HM_ORG", "HM_API_URL"])
211 .map(|key| match key.as_str() {
212 "HM_ORG" => "cloud.org".into(),
213 "HM_API_URL" => "cloud.api_url".into(),
214 other => other.into(),
215 })
216 .split(".")
217}
218
219#[cfg(test)]
220#[allow(clippy::unwrap_used)]
221mod tests {
222 use super::*;
223 use std::io::Write as _;
224 use std::sync::{Mutex, MutexGuard};
225
226 static ENV_LOCK: Mutex<()> = Mutex::new(());
234
235 fn env_guard() -> MutexGuard<'static, ()> {
236 ENV_LOCK
237 .lock()
238 .unwrap_or_else(std::sync::PoisonError::into_inner)
239 }
240
241 #[test]
242 fn app_url_maps_prod_api_to_app() {
243 assert_eq!(app_url(DEFAULT_API_URL, None), "https://app.harmont.dev");
244 }
245
246 #[test]
247 fn app_url_override_wins_and_trims_trailing_slash() {
248 assert_eq!(
249 app_url(DEFAULT_API_URL, Some("http://localhost:5173/")),
250 "http://localhost:5173"
251 );
252 }
253
254 #[test]
255 fn app_url_empty_override_is_ignored() {
256 assert_eq!(
257 app_url(DEFAULT_API_URL, Some(" ")),
258 "https://app.harmont.dev"
259 );
260 }
261
262 #[test]
263 fn app_url_falls_back_to_api_for_unmapped_host() {
264 assert_eq!(
265 app_url("http://localhost:4000", None),
266 "http://localhost:4000"
267 );
268 assert_eq!(app_url("http://api.dev.test/", None), "http://app.dev.test");
270 }
271
272 #[test]
273 fn default_config_values() {
274 let cfg = Config::default();
275 assert_eq!(cfg.backend, Backend::Docker);
276 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
277 assert!(cfg.cloud.org.is_none());
278 assert!(cfg.cloud.pipeline.is_none());
279 assert_eq!(cfg.preferences.format, "human");
280 assert!(!cfg.preferences.auto_watch);
281 }
282
283 #[test]
284 fn deserialize_full_toml() {
285 let toml_str = r#"
286[cloud]
287org = "acme"
288api_url = "https://custom.api"
289pipeline = "acme/web"
290
291[preferences]
292format = "json"
293auto_watch = true
294"#;
295 let cfg: Config = toml::from_str(toml_str).unwrap();
296 assert_eq!(cfg.cloud.org.as_deref(), Some("acme"));
297 assert_eq!(cfg.cloud.api_url, "https://custom.api");
298 assert_eq!(cfg.cloud.pipeline.as_deref(), Some("acme/web"));
299 assert_eq!(cfg.preferences.format, "json");
300 assert!(cfg.preferences.auto_watch);
301 }
302
303 #[test]
304 fn deserialize_sparse_toml() {
305 let _g = env_guard();
306 let toml_str = r#"
307[cloud]
308org = "sparse-co"
309"#;
310 let mut f = tempfile::NamedTempFile::new().unwrap();
311 f.write_all(toml_str.as_bytes()).unwrap();
312
313 let cfg = Config::load_from_paths(Some(f.path()), None).unwrap();
314 assert_eq!(cfg.cloud.org.as_deref(), Some("sparse-co"));
315 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
316 assert_eq!(cfg.preferences.format, "human");
317 assert!(!cfg.preferences.auto_watch);
318 }
319
320 #[test]
321 fn deserialize_empty_toml() {
322 let _g = env_guard();
323 let mut f = tempfile::NamedTempFile::new().unwrap();
324 f.write_all(b"").unwrap();
325
326 let cfg = Config::load_from_paths(Some(f.path()), None).unwrap();
327 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
328 assert!(cfg.cloud.org.is_none());
329 assert_eq!(cfg.preferences.format, "human");
330 assert!(!cfg.preferences.auto_watch);
331 }
332
333 #[test]
334 fn figment_project_overrides_user() {
335 let _g = env_guard();
336 let user_toml = r#"
337[cloud]
338org = "user-org"
339api_url = "https://user.api"
340
341[preferences]
342format = "json"
343"#;
344 let project_toml = r#"
345[cloud]
346org = "project-org"
347"#;
348
349 let mut user_file = tempfile::NamedTempFile::new().unwrap();
350 user_file.write_all(user_toml.as_bytes()).unwrap();
351
352 let mut project_file = tempfile::NamedTempFile::new().unwrap();
353 project_file.write_all(project_toml.as_bytes()).unwrap();
354
355 let cfg =
356 Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap();
357
358 assert_eq!(cfg.cloud.org.as_deref(), Some("project-org"));
359 assert_eq!(cfg.cloud.api_url, "https://user.api");
360 assert_eq!(cfg.preferences.format, "json");
361 }
362
363 #[test]
364 fn backend_display_matches_wire_strings() {
365 assert_eq!(Backend::Docker.to_string(), "docker");
366 assert_eq!(Backend::Cloud.to_string(), "cloud");
367 }
368
369 #[test]
370 fn backend_defaults_docker_and_parses_and_layers() {
371 let _g = env_guard();
372 assert_eq!(Config::default().backend, Backend::Docker);
374
375 let mut user_file = tempfile::NamedTempFile::new().unwrap();
377 user_file.write_all(br#"backend = "cloud""#).unwrap();
378
379 let mut project_file = tempfile::NamedTempFile::new().unwrap();
380 project_file.write_all(br#"backend = "docker""#).unwrap();
381
382 let cfg =
383 Config::load_from_paths(Some(user_file.path()), Some(project_file.path())).unwrap();
384 assert_eq!(cfg.backend, Backend::Docker);
385
386 let cfg_user = Config::load_from_paths(Some(user_file.path()), None).unwrap();
388 assert_eq!(cfg_user.backend, Backend::Cloud);
389 }
390
391 #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
392 async fn save_and_reload_roundtrip() {
393 let _g = env_guard();
394 let tmp = tempfile::tempdir().unwrap();
395 let path = tmp.path().join("config.toml");
396 let cfg = Config {
397 cloud: CloudConfig {
398 org: Some("saved-org".into()),
399 pipeline: Some("saved-org/web".into()),
400 ..CloudConfig::default()
401 },
402 ..Config::default()
403 };
404 cfg.save_to(&path).unwrap();
405
406 let loaded = Config::load_from_paths(Some(&path), None).unwrap();
407 assert_eq!(loaded.cloud.org.as_deref(), Some("saved-org"));
408 assert_eq!(loaded.cloud.pipeline.as_deref(), Some("saved-org/web"));
409 assert_eq!(loaded.cloud.api_url, DEFAULT_API_URL);
410 assert_eq!(loaded.preferences.format, "human");
411 }
412
413 #[test]
414 #[allow(clippy::result_large_err)] fn hm_env_overrides_cloud_keys() {
416 let _g = env_guard();
417 figment::Jail::expect_with(|jail| {
419 jail.set_env("HM_ORG", "env-org");
420 jail.set_env("HM_API_URL", "https://env.api");
421
422 let cfg = Config::load_from_paths(None, None).unwrap();
423 assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
424 assert_eq!(cfg.cloud.api_url, "https://env.api");
425 Ok(())
426 });
427 }
428
429 #[test]
430 #[allow(clippy::result_large_err)] fn hm_env_overrides_user_file() {
432 let _g = env_guard();
433 figment::Jail::expect_with(|jail| {
435 jail.set_env("HM_ORG", "env-org");
436
437 jail.create_file(
438 "config.toml",
439 "[cloud]\norg = \"file-org\"\napi_url = \"https://file.api\"\n",
440 )?;
441 let user = jail.directory().join("config.toml");
442
443 let cfg = Config::load_from_paths(Some(&user), None).unwrap();
444 assert_eq!(cfg.cloud.org.as_deref(), Some("env-org"));
445 assert_eq!(cfg.cloud.api_url, "https://file.api");
447 Ok(())
448 });
449 }
450
451 #[test]
452 fn figment_missing_files_still_resolve() {
453 let _g = env_guard();
454 let nonexistent_user = Path::new("/tmp/harmont-test-nonexistent-user/config.toml");
455 let nonexistent_project = Path::new("/tmp/harmont-test-nonexistent-project/config.toml");
456
457 let cfg =
458 Config::load_from_paths(Some(nonexistent_user), Some(nonexistent_project)).unwrap();
459
460 assert_eq!(cfg.cloud.api_url, DEFAULT_API_URL);
461 assert!(cfg.cloud.org.is_none());
462 assert_eq!(cfg.preferences.format, "human");
463 assert!(!cfg.preferences.auto_watch);
464 }
465}