Skip to main content

homeassistant_cli/
config.rs

1use 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/// Resolved credentials for the active profile.
23#[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
127/// Write or update a single profile in the config file.
128pub 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
157/// Return all profile names from the config file (default first).
158pub 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
172/// Return (url, token) for an existing profile, or None if not present.
173pub 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    // Prefer XDG_CONFIG_HOME when set (cross-platform and testable on macOS).
186    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}