1use 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 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 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 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 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}