Skip to main content

runtime_cli/
config.rs

1//! On-disk config: `~/.config/runtime/config.json`.
2//!
3//! The file holds the host URL, username, and PAT minted via
4//! `runtime auth login`. The PAT is the only secret on the filesystem;
5//! file mode is clamped to `0600` on Unix. Callers that want to point
6//! the CLI at a different file (tests, sandboxes) set
7//! `RUNTIME_CONFIG_HOME` to override the parent directory.
8
9use std::fs;
10use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, Context, Result};
13use serde::{Deserialize, Serialize};
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
16pub struct Config {
17    pub host: String,
18    pub username: String,
19    pub token: String,
20}
21
22impl Config {
23    /// Compute the config-file path the CLI uses by default. Honors
24    /// `RUNTIME_CONFIG_HOME` first (tests), then `XDG_CONFIG_HOME`,
25    /// then falls back to `~/.config`.
26    pub fn default_path() -> Result<PathBuf> {
27        if let Ok(dir) = std::env::var("RUNTIME_CONFIG_HOME") {
28            return Ok(PathBuf::from(dir).join("config.json"));
29        }
30        let base = std::env::var("XDG_CONFIG_HOME")
31            .map(PathBuf::from)
32            .ok()
33            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
34            .ok_or_else(|| {
35                anyhow!("could not resolve config dir: set $HOME or $XDG_CONFIG_HOME")
36            })?;
37        Ok(base.join("runtime").join("config.json"))
38    }
39
40    /// Load the config from `default_path()`. Returns an error if the
41    /// file is missing — callers may surface a "run `runtime auth login`"
42    /// hint.
43    pub fn load() -> Result<Self> {
44        let path = Self::default_path()?;
45        Self::load_from(&path)
46    }
47
48    pub fn load_from(path: &Path) -> Result<Self> {
49        let bytes = fs::read(path).with_context(|| {
50            format!(
51                "no Runtime config at {} — run `runtime auth login` first",
52                path.display()
53            )
54        })?;
55        let cfg: Config = serde_json::from_slice(&bytes)
56            .with_context(|| format!("invalid config JSON at {}", path.display()))?;
57        Ok(cfg)
58    }
59
60    /// Persist this config, creating the parent directory and clamping
61    /// permissions to `0600` on Unix. Overwrites any previous file.
62    pub fn save(&self) -> Result<PathBuf> {
63        let path = Self::default_path()?;
64        self.save_to(&path)?;
65        Ok(path)
66    }
67
68    pub fn save_to(&self, path: &Path) -> Result<()> {
69        if let Some(parent) = path.parent() {
70            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
71        }
72        let json = serde_json::to_vec_pretty(self)?;
73        fs::write(path, &json).with_context(|| format!("write {}", path.display()))?;
74        #[cfg(unix)]
75        {
76            use std::os::unix::fs::PermissionsExt;
77            let mut perms = fs::metadata(path)?.permissions();
78            perms.set_mode(0o600);
79            fs::set_permissions(path, perms)?;
80        }
81        Ok(())
82    }
83
84    /// Remove the on-disk config. Returns `Ok(false)` if there was no
85    /// file to delete.
86    pub fn delete() -> Result<bool> {
87        let path = Self::default_path()?;
88        match fs::remove_file(&path) {
89            Ok(()) => Ok(true),
90            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
91            Err(e) => Err(e).with_context(|| format!("delete {}", path.display())),
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn save_then_load_round_trips() {
102        let tmp = tempfile::tempdir().expect("tmp");
103        let path = tmp.path().join("config.json");
104        let cfg = Config {
105            host: "https://runtime.example".into(),
106            username: "alice".into(),
107            token: "pat_xyz".into(),
108        };
109        cfg.save_to(&path).expect("save");
110        let loaded = Config::load_from(&path).expect("load");
111        assert_eq!(loaded, cfg);
112        #[cfg(unix)]
113        {
114            use std::os::unix::fs::PermissionsExt;
115            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
116            assert_eq!(mode, 0o600);
117        }
118    }
119
120    #[test]
121    fn load_missing_returns_helpful_error() {
122        let tmp = tempfile::tempdir().expect("tmp");
123        let path = tmp.path().join("nope.json");
124        let err = Config::load_from(&path).unwrap_err();
125        let msg = format!("{err:#}");
126        assert!(msg.contains("runtime auth login"), "{msg}");
127    }
128}