Skip to main content

homeassistant_cli/commands/
init.rs

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
141/// Interactive init flow with injectable IO and async validator for testing.
142///
143/// `validate` receives (url, token) and returns `Some(display_name)` on success
144/// or `None` on auth failure.
145pub 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
290/// Entry point from main — uses real stdin/stdout and live API validation.
291pub 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        // \n accepts "update" default at action prompt, then Enter to keep both fields
435        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}