1use std::future::Future;
2use std::io::{BufRead, IsTerminal, Write};
3use std::path::Path;
4
5use owo_colors::OwoColorize;
6
7use crate::api::HaError;
8use crate::config;
9use crate::output;
10
11const SEP: &str = "──────────────────────────────────────";
12
13fn sym_q() -> String {
14 "?".green().bold().to_string()
15}
16
17fn sym_ok() -> String {
18 "✔".green().to_string()
19}
20
21fn sym_fail() -> String {
22 "✖".red().to_string()
23}
24
25fn sym_dim(s: &str) -> String {
26 s.dimmed().to_string()
27}
28
29fn prompt_optional<R: BufRead, W: Write>(
30 r: &mut R,
31 w: &mut W,
32 label: &str,
33 default: &str,
34) -> String {
35 let _ = write!(w, "{} {} [{}]: ", sym_q(), label, sym_dim(default));
36 let _ = w.flush();
37 let mut input = String::new();
38 r.read_line(&mut input).unwrap_or(0);
39 let trimmed = input.trim().to_owned();
40 if trimmed.is_empty() {
41 default.to_owned()
42 } else {
43 trimmed
44 }
45}
46
47fn prompt_required<R: BufRead, W: Write>(
48 r: &mut R,
49 w: &mut W,
50 label: &str,
51 hint: &str,
52) -> Option<String> {
53 loop {
54 let _ = write!(
55 w,
56 "{} {} {}: ",
57 sym_q(),
58 label,
59 sym_dim(&format!("[{hint}]"))
60 );
61 let _ = w.flush();
62 let mut input = String::new();
63 match r.read_line(&mut input) {
64 Ok(0) | Err(_) => return None,
65 Ok(_) => {}
66 }
67 let trimmed = input.trim().to_owned();
68 if !trimmed.is_empty() {
69 return Some(trimmed);
70 }
71 let _ = writeln!(w, " {} {} is required.", sym_fail(), label);
72 }
73}
74
75fn prompt_credential_update<R: BufRead, W: Write>(
76 r: &mut R,
77 w: &mut W,
78 label: &str,
79 current: &str,
80) -> Option<String> {
81 let hint = format!("{} (Enter to keep)", output::mask_credential(current));
82 let _ = write!(w, "{} {} {}: ", sym_q(), label, sym_dim(&hint));
83 let _ = w.flush();
84 let mut input = String::new();
85 match r.read_line(&mut input) {
86 Ok(0) | Err(_) => return None,
87 Ok(_) => {}
88 }
89 let trimmed = input.trim().to_owned();
90 Some(if trimmed.is_empty() {
91 current.to_owned()
92 } else {
93 trimmed
94 })
95}
96
97fn prompt_confirm<R: BufRead, W: Write>(
98 r: &mut R,
99 w: &mut W,
100 label: &str,
101 default_yes: bool,
102) -> bool {
103 let hint = if default_yes { "Y/n" } else { "y/N" };
104 let _ = write!(w, "{} {} [{}]: ", sym_q(), label, sym_dim(hint));
105 let _ = w.flush();
106 let mut input = String::new();
107 r.read_line(&mut input).unwrap_or(0);
108 match input.trim().to_lowercase().as_str() {
109 "y" | "yes" => true,
110 "n" | "no" => false,
111 _ => default_yes,
112 }
113}
114
115fn print_json_schema(config_path: &Path) {
116 let path_str = config_path.to_string_lossy();
117 let schema = serde_json::json!({
118 "configPath": path_str,
119 "pathResolution": config::schema_config_path_description(),
120 "recommendedPermissions": config::recommended_permissions(config_path),
121 "tokenInstructions": {
122 "steps": [
123 "Open Home Assistant in your browser",
124 "Go to Settings → Profile (bottom left)",
125 "Scroll to 'Long-Lived Access Tokens'",
126 "Click 'Create Token', give it a name, copy it"
127 ]
128 },
129 "requiredFields": ["url", "token"],
130 "example": {
131 "configFile": path_str,
132 "format": "[default]\nurl = \"http://homeassistant.local:8123\"\ntoken = \"YOUR_LONG_LIVED_TOKEN\""
133 }
134 });
135 println!(
136 "{}",
137 serde_json::to_string_pretty(&schema).expect("serialize")
138 );
139}
140
141pub async fn run_init<R, W, Fut>(
146 reader: &mut R,
147 writer: &mut W,
148 config_path: &Path,
149 profile_arg: Option<&str>,
150 validate: impl Fn(String, String) -> Fut,
151) -> Result<(), HaError>
152where
153 R: BufRead,
154 W: Write,
155 Fut: Future<Output = Option<String>>,
156{
157 let _ = writeln!(writer, "\nHome Assistant CLI");
158 let _ = writeln!(writer, "{SEP}\n");
159
160 let existing_profiles = config::read_profile_names(config_path);
161 let is_first_setup = existing_profiles.is_empty();
162
163 let (profile_name, is_update) = if let Some(p) = profile_arg {
164 let is_update = existing_profiles.contains(&p.to_owned());
165 (p.to_owned(), is_update)
166 } else if is_first_setup {
167 ("default".to_owned(), false)
168 } else {
169 if existing_profiles.len() == 1 {
170 let p = &existing_profiles[0];
171 let cred = config::read_profile_credentials(config_path, p)
172 .map(|(url, _)| format!(" {}", output::mask_credential(&url)))
173 .unwrap_or_default();
174 let _ = writeln!(writer, " Profile: {}{}\n", p.bold(), sym_dim(&cred));
175 } else {
176 let _ = writeln!(writer, " Profiles:");
177 for p in &existing_profiles {
178 let cred = config::read_profile_credentials(config_path, p)
179 .map(|(url, _)| format!(" {}", output::mask_credential(&url)))
180 .unwrap_or_default();
181 let _ = writeln!(writer, " {}{}", p, sym_dim(&cred));
182 }
183 let _ = writeln!(writer);
184 }
185
186 let action = prompt_optional(reader, writer, "Action [update/add]", "update");
187 let _ = writeln!(writer);
188
189 if action.trim().eq_ignore_ascii_case("add") {
190 let Some(name) = prompt_required(reader, writer, "Profile name", "e.g. prod") else {
191 let _ = writeln!(writer, "\nAborted.");
192 return Ok(());
193 };
194 (name, false)
195 } else if existing_profiles.len() == 1 {
196 (existing_profiles[0].clone(), true)
197 } else {
198 let options = existing_profiles.join("/");
199 let chosen = prompt_optional(
200 reader,
201 writer,
202 &format!("Profile [{}]", options),
203 &existing_profiles[0],
204 );
205 let profile = chosen.trim().to_owned();
206 if !existing_profiles.contains(&profile) {
207 let _ = writeln!(writer, "\n {} Unknown profile '{}'.", sym_fail(), profile);
208 return Ok(());
209 }
210 (profile, true)
211 }
212 };
213
214 let (url, token) = if is_update {
215 let (cur_url, cur_token) = config::read_profile_credentials(config_path, &profile_name)
216 .expect("update mode requires existing credentials");
217 let Some(url) = prompt_credential_update(reader, writer, "URL", &cur_url) else {
218 let _ = writeln!(writer, "\nAborted.");
219 return Ok(());
220 };
221 let Some(token) = prompt_credential_update(reader, writer, "Token", &cur_token) else {
222 let _ = writeln!(writer, "\nAborted.");
223 return Ok(());
224 };
225 (url, token)
226 } else {
227 let Some(url) = prompt_required(
228 reader,
229 writer,
230 "Home Assistant URL",
231 "http://homeassistant.local:8123",
232 ) else {
233 let _ = writeln!(writer, "\nAborted.");
234 return Ok(());
235 };
236 let Some(token) = prompt_required(
237 reader,
238 writer,
239 "Long-Lived Access Token",
240 "from HA Settings → Profile",
241 ) else {
242 let _ = writeln!(writer, "\nAborted.");
243 return Ok(());
244 };
245 (url, token)
246 };
247
248 let _ = write!(writer, "\n Verifying credentials...");
249 let _ = writer.flush();
250 let validation = validate(url.clone(), token.clone()).await;
251
252 let save = match validation {
253 Some(name) => {
254 let _ = writeln!(writer, " {} Connected to {}", sym_ok(), name.bold());
255 true
256 }
257 None => {
258 let _ = writeln!(writer, " {} Could not connect.", sym_fail());
259 prompt_confirm(reader, writer, "Save anyway?", false)
260 }
261 };
262
263 if !save {
264 let _ = writeln!(writer, "\nAborted. Config not saved.");
265 let _ = writer.flush();
266 return Ok(());
267 }
268
269 config::write_profile(config_path, &profile_name, &url, &token)?;
270
271 let run_cmd = if profile_name == "default" {
272 "ha entity list".to_owned()
273 } else {
274 format!("ha --profile {} entity list", profile_name)
275 };
276
277 let _ = writeln!(writer, "\n{SEP}");
278 let _ = writeln!(
279 writer,
280 " {} Config saved to {}",
281 sym_ok(),
282 sym_dim(&config_path.display().to_string())
283 );
284 let _ = writeln!(writer, " Run: {}", run_cmd.bold());
285 let _ = writer.flush();
286
287 Ok(())
288}
289
290pub async fn init(profile_arg: Option<String>) {
292 let config_path = config::config_path();
293
294 if !std::io::stdout().is_terminal() {
295 print_json_schema(&config_path);
296 return;
297 }
298
299 let stdin = std::io::stdin();
300 let stdout = std::io::stdout();
301 let mut reader = std::io::BufReader::new(stdin.lock());
302 let mut writer = std::io::BufWriter::new(stdout.lock());
303
304 if let Err(e) = run_init(
305 &mut reader,
306 &mut writer,
307 &config_path,
308 profile_arg.as_deref(),
309 |url, token| async move {
310 let client = crate::api::HaClient::new(&url, &token);
311 client.validate().await.ok()
312 },
313 )
314 .await
315 {
316 eprintln!("{} {e}", sym_fail());
317 std::process::exit(crate::output::exit_codes::GENERAL_ERROR);
318 }
319}
320
321#[cfg(test)]
322mod tests {
323 use super::*;
324 use std::io::Cursor;
325 use tempfile::TempDir;
326
327 fn fake_path(dir: &TempDir) -> std::path::PathBuf {
328 dir.path().join("config.toml")
329 }
330
331 #[tokio::test]
332 async fn init_writes_config_on_valid_credentials() {
333 let dir = TempDir::new().unwrap();
334 let path = fake_path(&dir);
335 let input = b"http://ha.local:8123\nmytoken\n";
336 let mut reader = Cursor::new(input.as_ref());
337 let mut writer = Vec::<u8>::new();
338
339 run_init(
340 &mut reader,
341 &mut writer,
342 &path,
343 None,
344 |_url, _token| async { Some("Home Assistant".to_string()) },
345 )
346 .await
347 .unwrap();
348
349 let saved = std::fs::read_to_string(&path).unwrap();
350 assert!(saved.contains("http://ha.local:8123"));
351 assert!(saved.contains("mytoken"));
352 }
353
354 #[tokio::test]
355 async fn init_uses_default_profile_on_first_setup() {
356 let dir = TempDir::new().unwrap();
357 let path = fake_path(&dir);
358 let input = b"http://ha.local:8123\nmytoken\n";
359 let mut reader = Cursor::new(input.as_ref());
360 let mut writer = Vec::<u8>::new();
361
362 run_init(&mut reader, &mut writer, &path, None, |_, _| async {
363 Some("HA".into())
364 })
365 .await
366 .unwrap();
367
368 let saved = std::fs::read_to_string(&path).unwrap();
369 assert!(saved.contains("[default]"));
370 }
371
372 #[tokio::test]
373 async fn init_aborts_when_validation_fails_and_user_declines() {
374 let dir = TempDir::new().unwrap();
375 let path = fake_path(&dir);
376 let input = b"http://ha.local:8123\nbadtoken\nn\n";
377 let mut reader = Cursor::new(input.as_ref());
378 let mut writer = Vec::<u8>::new();
379
380 run_init(&mut reader, &mut writer, &path, None, |_, _| async { None })
381 .await
382 .unwrap();
383
384 assert!(!path.exists(), "config must not be written after abort");
385 }
386
387 #[tokio::test]
388 async fn init_saves_when_validation_fails_but_user_forces() {
389 let dir = TempDir::new().unwrap();
390 let path = fake_path(&dir);
391 let input = b"http://ha.local:8123\nbadtoken\ny\n";
392 let mut reader = Cursor::new(input.as_ref());
393 let mut writer = Vec::<u8>::new();
394
395 run_init(&mut reader, &mut writer, &path, None, |_, _| async { None })
396 .await
397 .unwrap();
398
399 assert!(path.exists());
400 }
401
402 #[tokio::test]
403 async fn init_with_profile_arg_writes_named_profile() {
404 let dir = TempDir::new().unwrap();
405 let path = fake_path(&dir);
406 let input = b"http://ha.prod:8123\nprodtoken\n";
407 let mut reader = Cursor::new(input.as_ref());
408 let mut writer = Vec::<u8>::new();
409
410 run_init(
411 &mut reader,
412 &mut writer,
413 &path,
414 Some("prod"),
415 |_, _| async { Some("HA".into()) },
416 )
417 .await
418 .unwrap();
419
420 let saved = std::fs::read_to_string(&path).unwrap();
421 assert!(saved.contains("[prod]"));
422 }
423
424 #[tokio::test]
425 async fn init_update_keeps_values_on_enter() {
426 let dir = TempDir::new().unwrap();
427 let path = fake_path(&dir);
428 std::fs::write(
429 &path,
430 "[default]\nurl = \"http://ha.local:8123\"\ntoken = \"existing-token\"\n",
431 )
432 .unwrap();
433
434 let input = b"\n\n\n";
436 let mut reader = Cursor::new(input.as_ref());
437 let mut writer = Vec::<u8>::new();
438
439 run_init(&mut reader, &mut writer, &path, None, |_, _| async {
440 Some("HA".into())
441 })
442 .await
443 .unwrap();
444
445 let saved = std::fs::read_to_string(&path).unwrap();
446 assert!(saved.contains("existing-token"));
447 }
448
449 #[tokio::test]
450 async fn init_outro_includes_profile_flag_for_non_default() {
451 let dir = TempDir::new().unwrap();
452 let path = fake_path(&dir);
453 let input = b"http://ha.local:8123\ntoken\n";
454 let mut reader = Cursor::new(input.as_ref());
455 let mut writer = Vec::<u8>::new();
456
457 run_init(
458 &mut reader,
459 &mut writer,
460 &path,
461 Some("staging"),
462 |_, _| async { Some("HA".into()) },
463 )
464 .await
465 .unwrap();
466
467 let output = String::from_utf8_lossy(&writer);
468 assert!(output.contains("--profile staging"));
469 }
470
471 #[tokio::test]
472 async fn init_aborts_on_eof() {
473 let dir = TempDir::new().unwrap();
474 let path = fake_path(&dir);
475 let input = b"";
476 let mut reader = Cursor::new(input.as_ref());
477 let mut writer = Vec::<u8>::new();
478
479 run_init(&mut reader, &mut writer, &path, None, |_, _| async {
480 Some("HA".into())
481 })
482 .await
483 .unwrap();
484
485 assert!(!path.exists());
486 let output = String::from_utf8_lossy(&writer);
487 assert!(output.contains("Aborted"));
488 }
489}