homeassistant_cli/
config.rs1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use serde::{Deserialize, Serialize};
5
6use crate::api::HaError;
7
8#[derive(Debug, Deserialize, Serialize, Default, Clone)]
9struct RawProfile {
10 pub url: Option<String>,
11 pub token: Option<String>,
12}
13
14#[derive(Debug, Deserialize, Serialize, Default)]
15struct RawConfig {
16 #[serde(default)]
17 default: RawProfile,
18 #[serde(flatten)]
19 profiles: BTreeMap<String, RawProfile>,
20}
21
22#[derive(Debug, Clone)]
24pub struct Config {
25 pub url: String,
26 pub token: String,
27}
28
29impl Config {
30 pub fn load(profile_arg: Option<String>) -> Result<Self, HaError> {
31 let file_profile = load_file_profile(profile_arg.as_deref())?;
32
33 let url = std::env::var("HA_URL")
34 .ok()
35 .filter(|s| !s.is_empty())
36 .or_else(|| file_profile.url.filter(|s| !s.is_empty()))
37 .ok_or_else(|| {
38 HaError::InvalidInput("No url configured. Run 'ha init' or set HA_URL.".into())
39 })?;
40
41 let token = std::env::var("HA_TOKEN")
42 .ok()
43 .filter(|s| !s.is_empty())
44 .or_else(|| file_profile.token.filter(|s| !s.is_empty()))
45 .ok_or_else(|| {
46 HaError::InvalidInput("No token configured. Run 'ha init' or set HA_TOKEN.".into())
47 })?;
48
49 Ok(Self { url, token })
50 }
51}
52
53fn load_file_profile(profile_arg: Option<&str>) -> Result<RawProfile, HaError> {
54 let path = config_path();
55 if !path.exists() {
56 return Ok(RawProfile::default());
57 }
58
59 let content = std::fs::read_to_string(&path)
60 .map_err(|e| HaError::Other(format!("Failed to read config: {e}")))?;
61
62 let raw: RawConfig = toml::from_str(&content)
63 .map_err(|e| HaError::Other(format!("Invalid config file: {e}")))?;
64
65 let profile_name = profile_arg
66 .map(|s| s.to_owned())
67 .or_else(|| std::env::var("HA_PROFILE").ok().filter(|s| !s.is_empty()))
68 .unwrap_or_else(|| "default".to_owned());
69
70 if profile_name == "default" {
71 return Ok(raw.default);
72 }
73
74 raw.profiles.get(&profile_name).cloned().ok_or_else(|| {
75 HaError::InvalidInput(format!("Profile '{profile_name}' not found in config."))
76 })
77}
78
79pub struct ProfileSummary {
80 pub name: String,
81 pub url: Option<String>,
82 pub token: Option<String>,
83}
84
85pub struct ConfigSummary {
86 pub config_file: PathBuf,
87 pub file_exists: bool,
88 pub profiles: Vec<ProfileSummary>,
89 pub env_url: Option<String>,
90 pub env_token: Option<String>,
91 pub env_profile: Option<String>,
92}
93
94pub fn config_summary() -> ConfigSummary {
95 let config_file = config_path();
96 let file_exists = config_file.exists();
97 let mut profiles = Vec::new();
98
99 if file_exists
100 && let Ok(content) = std::fs::read_to_string(&config_file)
101 && let Ok(raw) = toml::from_str::<RawConfig>(&content)
102 {
103 profiles.push(ProfileSummary {
104 name: "default".into(),
105 url: raw.default.url,
106 token: raw.default.token,
107 });
108 for (name, p) in raw.profiles {
109 profiles.push(ProfileSummary {
110 name,
111 url: p.url,
112 token: p.token,
113 });
114 }
115 }
116
117 ConfigSummary {
118 config_file,
119 file_exists,
120 profiles,
121 env_url: std::env::var("HA_URL").ok().filter(|s| !s.is_empty()),
122 env_token: std::env::var("HA_TOKEN").ok().filter(|s| !s.is_empty()),
123 env_profile: std::env::var("HA_PROFILE").ok().filter(|s| !s.is_empty()),
124 }
125}
126
127pub fn write_profile(path: &Path, profile: &str, url: &str, token: &str) -> Result<(), HaError> {
129 let mut raw: RawConfig = if path.exists() {
130 let content = std::fs::read_to_string(path).map_err(|e| HaError::Other(e.to_string()))?;
131 toml::from_str(&content).map_err(|e| HaError::Other(format!("Invalid config: {e}")))?
132 } else {
133 RawConfig::default()
134 };
135
136 let new_profile = RawProfile {
137 url: Some(url.to_owned()),
138 token: Some(token.to_owned()),
139 };
140
141 if profile == "default" {
142 raw.default = new_profile;
143 } else {
144 raw.profiles.insert(profile.to_owned(), new_profile);
145 }
146
147 if let Some(parent) = path.parent() {
148 std::fs::create_dir_all(parent).map_err(|e| HaError::Other(e.to_string()))?;
149 }
150
151 let content = toml::to_string(&raw).map_err(|e| HaError::Other(e.to_string()))?;
152 std::fs::write(path, content).map_err(|e| HaError::Other(e.to_string()))?;
153
154 Ok(())
155}
156
157pub fn read_profile_names(path: &Path) -> Vec<String> {
159 let content = match std::fs::read_to_string(path) {
160 Ok(c) => c,
161 Err(_) => return Vec::new(),
162 };
163 let raw: RawConfig = match toml::from_str(&content) {
164 Ok(r) => r,
165 Err(_) => return Vec::new(),
166 };
167 let mut names = vec!["default".to_owned()];
168 names.extend(raw.profiles.into_keys());
169 names
170}
171
172pub fn read_profile_credentials(path: &Path, profile: &str) -> Option<(String, String)> {
174 let content = std::fs::read_to_string(path).ok()?;
175 let raw: RawConfig = toml::from_str(&content).ok()?;
176 let p = if profile == "default" {
177 raw.default
178 } else {
179 raw.profiles.get(profile)?.clone()
180 };
181 Some((p.url?, p.token?))
182}
183
184pub fn config_path() -> PathBuf {
185 let base = std::env::var_os("XDG_CONFIG_HOME")
187 .map(PathBuf::from)
188 .filter(|p| p.is_absolute())
189 .or_else(dirs::config_dir)
190 .unwrap_or_else(|| PathBuf::from("~/.config"));
191 base.join("ha").join("config.toml")
192}
193
194pub fn schema_config_path_description() -> &'static str {
195 "~/.config/ha/config.toml (or $XDG_CONFIG_HOME/ha/config.toml)"
196}
197
198pub fn recommended_permissions(path: &Path) -> String {
199 format!("chmod 600 {}", path.display())
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::test_support::{EnvVarGuard, ProcessEnvLock, write_config};
206 use tempfile::TempDir;
207
208 #[test]
209 fn loads_default_profile_from_file() {
210 let _lock = ProcessEnvLock::acquire().unwrap();
211 let dir = TempDir::new().unwrap();
212 write_config(
213 dir.path(),
214 "[default]\nurl = \"http://ha.local:8123\"\ntoken = \"abc123\"\n",
215 )
216 .unwrap();
217 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", &dir.path().to_string_lossy());
218 let _url = EnvVarGuard::unset("HA_URL");
219 let _token = EnvVarGuard::unset("HA_TOKEN");
220
221 let cfg = Config::load(None).unwrap();
222 assert_eq!(cfg.url, "http://ha.local:8123");
223 assert_eq!(cfg.token, "abc123");
224 }
225
226 #[test]
227 fn env_vars_override_file() {
228 let _lock = ProcessEnvLock::acquire().unwrap();
229 let dir = TempDir::new().unwrap();
230 write_config(
231 dir.path(),
232 "[default]\nurl = \"http://ha.local:8123\"\ntoken = \"file-token\"\n",
233 )
234 .unwrap();
235 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", &dir.path().to_string_lossy());
236 let _url = EnvVarGuard::set("HA_URL", "http://override:8123");
237 let _token = EnvVarGuard::set("HA_TOKEN", "env-token");
238
239 let cfg = Config::load(None).unwrap();
240 assert_eq!(cfg.url, "http://override:8123");
241 assert_eq!(cfg.token, "env-token");
242 }
243
244 #[test]
245 fn named_profile_is_loaded() {
246 let _lock = ProcessEnvLock::acquire().unwrap();
247 let dir = TempDir::new().unwrap();
248 write_config(dir.path(), "[default]\nurl = \"http://default:8123\"\ntoken = \"t1\"\n\n[prod]\nurl = \"http://prod:8123\"\ntoken = \"t2\"\n").unwrap();
249 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", &dir.path().to_string_lossy());
250 let _url = EnvVarGuard::unset("HA_URL");
251 let _token = EnvVarGuard::unset("HA_TOKEN");
252
253 let cfg = Config::load(Some("prod".into())).unwrap();
254 assert_eq!(cfg.url, "http://prod:8123");
255 assert_eq!(cfg.token, "t2");
256 }
257
258 #[test]
259 fn missing_config_returns_err() {
260 let _lock = ProcessEnvLock::acquire().unwrap();
261 let dir = TempDir::new().unwrap();
262 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", &dir.path().to_string_lossy());
263 let _url = EnvVarGuard::unset("HA_URL");
264 let _token = EnvVarGuard::unset("HA_TOKEN");
265
266 let result = Config::load(None);
267 assert!(result.is_err());
268 let msg = result.unwrap_err().to_string();
269 assert!(msg.contains("ha init"), "should hint at ha init");
270 }
271
272 #[test]
273 fn write_profile_creates_file_and_reads_back() {
274 let dir = TempDir::new().unwrap();
275 let path = dir.path().join("config.toml");
276
277 write_profile(&path, "default", "http://ha.local:8123", "mytoken").unwrap();
278
279 let content = std::fs::read_to_string(&path).unwrap();
280 assert!(content.contains("[default]"));
281 assert!(content.contains("http://ha.local:8123"));
282 assert!(content.contains("mytoken"));
283 }
284
285 #[test]
286 fn config_path_uses_xdg_config_home() {
287 let _lock = ProcessEnvLock::acquire().unwrap();
288 let dir = TempDir::new().unwrap();
289 let _env = EnvVarGuard::set("XDG_CONFIG_HOME", &dir.path().to_string_lossy());
290
291 let path = config_path();
292 assert!(path.starts_with(dir.path()));
293 assert!(path.ends_with("config.toml"));
294 }
295}