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    /// Org-global pipeline slug to submit builds to directly (set by `hm run`
73    /// after registering a remoteless directory). When present, cloud runs
74    /// submit by this slug instead of resolving by git-repo identity.
75    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    /// XDG-aware user config path (`~/.config/hm/config.toml`).
117    ///
118    /// # Errors
119    ///
120    /// Returns an error if the platform config directory cannot be determined.
121    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    /// Project-level config path: `<root>/.hm/config.toml`.
127    #[must_use]
128    pub fn project_config_path(project_root: &Path) -> PathBuf {
129        project_root.join(".hm").join("config.toml")
130    }
131
132    /// Load configuration with full layering: defaults -> user file -> project file -> env.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the user config path cannot be determined or
137    /// figment extraction fails (malformed TOML, type mismatches).
138    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    /// Testable core: build a `Config` from explicit file paths.
146    ///
147    /// Layering, lowest to highest precedence: defaults -> user file ->
148    /// project file -> env.
149    ///
150    /// Env precedence (highest): both the `HM_`-prefixed split form
151    /// (`HM_CLOUD__ORG`, `HM_CLOUD__API_URL`) and the documented
152    /// `HM_ORG` / `HM_API_URL` are honored; the latter map onto
153    /// `cloud.org` / `cloud.api_url`.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if figment extraction fails (malformed TOML, type mismatches).
158    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    /// Persist config to `path` atomically.
176    ///
177    /// # Errors
178    ///
179    /// Returns an error if TOML serialization fails or the atomic write fails.
180    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    /// Save to user-level config path (`~/.config/hm/config.toml`).
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the path cannot be determined or the write fails.
196    pub fn save_user(&self) -> Result<()> {
197        self.save_to(&Self::user_config_path()?)
198    }
199}
200
201/// Figment env provider mapping the friendly `HM_ORG` / `HM_API_URL`
202/// variables onto the nested `cloud` config keys.
203///
204/// The cloud settings docs and `hm`'s error messages tell users to
205/// `set HM_ORG=<slug>` / `HM_API_URL=<url>`, so those flat names must feed
206/// the config. This binds them to `cloud.org` / `cloud.api_url` alongside the
207/// generic `HM_`-prefixed split layer (`HM_CLOUD__ORG`, …).
208fn 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    /// Serializes every test that resolves config through `load_*`.
227    ///
228    /// All `load_*` paths merge the process environment as their top layer, so
229    /// a test that sets `HM_*` (via `figment::Jail`, which mutates the
230    /// real process env for the duration of its closure) would otherwise leak
231    /// into a concurrently-running file-layering test. Holding this lock for
232    /// the whole body of any env-or-load test makes them mutually exclusive.
233    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        // http api. → http app.
269        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        // default
373        assert_eq!(Config::default().backend, Backend::Docker);
374
375        // user file sets cloud; project file sets docker -> project wins.
376        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        // user file alone parses "cloud".
387        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)] // figment::Error is the Jail closure's error type.
415    fn hm_env_overrides_cloud_keys() {
416        let _g = env_guard();
417        // `Jail` isolates env mutation from concurrently-running tests.
418        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)] // figment::Error is the Jail closure's error type.
431    fn hm_env_overrides_user_file() {
432        let _g = env_guard();
433        // Env is the highest-precedence layer: it wins over a user file.
434        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            // Unset env keys still come from the file.
446            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}