Skip to main content

hm_config/
lib.rs

1//! Layered (project/user/env) configuration and credential storage for the
2//! `hm` CLI. Shared between the `hm` binary and `hm-plugin-cloud` so both sides
3//! resolve config and credentials through one source of truth.
4
5use 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/// Execution backend for `hm run`.
19///
20/// Closed set parsed at the config boundary so invalid values are rejected at
21/// deserialize time instead of mis-dispatching later, and every consumer match
22/// is exhaustively checked by the compiler.
23///
24/// The `#[display(...)]` strings are the stable lowercase wire/CLI names and
25/// must match the `#[serde(rename_all = "lowercase")]` representation.
26#[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/// Derive the SPA (dashboard) base URL from the API base.
39///
40/// The CLI talks to `api.harmont.dev`, but a human clicks through to the
41/// dashboard at `app.harmont.dev`. A watch/login link built from the API host
42/// lands on raw JSON, so every surface that emits a user-clickable URL must map
43/// the host first.
44///
45/// Priority:
46/// 1. `override_url` (e.g. the `HM_APP_URL` env override) when non-empty,
47/// 2. heuristic mapping of `api.` → `app.` on the API host,
48/// 3. the API base itself (last-resort dev fallback for hosts like
49///    `localhost` that have no `api.`/`app.` split).
50///
51/// The returned URL never has a trailing slash.
52#[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    /// XDG-aware user config path (`~/.config/hm/config.toml`).
112    ///
113    /// # Errors
114    ///
115    /// Returns an error if the platform config directory cannot be determined.
116    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    /// Project-level config path: `<root>/.hm/config.toml`.
122    #[must_use]
123    pub fn project_config_path(project_root: &Path) -> PathBuf {
124        project_root.join(".hm").join("config.toml")
125    }
126
127    /// Load configuration with full layering: defaults -> user file -> project file -> env.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the user config path cannot be determined or
132    /// figment extraction fails (malformed TOML, type mismatches).
133    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    /// Testable core: build a `Config` from explicit file paths.
141    ///
142    /// Layering, lowest to highest precedence: defaults -> user file ->
143    /// project file -> env.
144    ///
145    /// Env precedence (highest): both the `HM_`-prefixed split form
146    /// (`HM_CLOUD__ORG`, `HM_CLOUD__API_URL`) and the documented
147    /// `HM_ORG` / `HM_API_URL` are honored; the latter map onto
148    /// `cloud.org` / `cloud.api_url`.
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if figment extraction fails (malformed TOML, type mismatches).
153    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    /// Persist config to `path` atomically.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if TOML serialization fails or the atomic write fails.
175    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    /// Save to user-level config path (`~/.config/hm/config.toml`).
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the path cannot be determined or the write fails.
191    pub fn save_user(&self) -> Result<()> {
192        self.save_to(&Self::user_config_path()?)
193    }
194}
195
196/// Figment env provider mapping the friendly `HM_ORG` / `HM_API_URL`
197/// variables onto the nested `cloud` config keys.
198///
199/// The cloud settings docs and `hm`'s error messages tell users to
200/// `set HM_ORG=<slug>` / `HM_API_URL=<url>`, so those flat names must feed
201/// the config. This binds them to `cloud.org` / `cloud.api_url` alongside the
202/// generic `HM_`-prefixed split layer (`HM_CLOUD__ORG`, …).
203fn 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    /// Serializes every test that resolves config through `load_*`.
222    ///
223    /// All `load_*` paths merge the process environment as their top layer, so
224    /// a test that sets `HM_*` (via `figment::Jail`, which mutates the
225    /// real process env for the duration of its closure) would otherwise leak
226    /// into a concurrently-running file-layering test. Holding this lock for
227    /// the whole body of any env-or-load test makes them mutually exclusive.
228    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        // http api. → http app.
264        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        // default
365        assert_eq!(Config::default().backend, Backend::Docker);
366
367        // user file sets cloud; project file sets docker -> project wins.
368        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        // user file alone parses "cloud".
379        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)] // figment::Error is the Jail closure's error type.
405    fn hm_env_overrides_cloud_keys() {
406        let _g = env_guard();
407        // `Jail` isolates env mutation from concurrently-running tests.
408        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)] // figment::Error is the Jail closure's error type.
421    fn hm_env_overrides_user_file() {
422        let _g = env_guard();
423        // Env is the highest-precedence layer: it wins over a user file.
424        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            // Unset env keys still come from the file.
436            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}