Skip to main content

zoom_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::ApiError;
8use crate::config;
9use crate::output;
10
11const CORE_SCOPES: &[&str] = &[
12    "meeting:read:list_meetings:master",
13    "meeting:read:meeting:master",
14    "meeting:write:meeting:master",
15    "recording:read:list_user_recordings:master",
16    "user:read:user:master",
17    "user:read:list_users:master",
18];
19
20const OPTIONAL_SCOPES: &[&str] = &[
21    "meeting:write:meeting:admin",
22    "meeting:read:list_past_meeting_participants:admin",
23    "report:read:user:admin",
24    "recording:write:recording:master",
25];
26
27const OAUTH_URL: &str = "https://marketplace.zoom.us/develop/create";
28const SEP: &str = "──────────────────────────────────────";
29
30fn sym_q() -> String {
31    "?".green().bold().to_string()
32}
33
34fn sym_ok() -> String {
35    "✔".green().to_string()
36}
37
38fn sym_fail() -> String {
39    "✖".red().to_string()
40}
41
42fn sym_dim(s: &str) -> String {
43    s.dimmed().to_string()
44}
45
46/// Prompt with a default value. Returns the default when the user presses Enter.
47fn prompt_optional<R: BufRead, W: Write>(
48    reader: &mut R,
49    writer: &mut W,
50    label: &str,
51    default: &str,
52) -> String {
53    let _ = write!(writer, "{} {}  [{}]: ", sym_q(), label, sym_dim(default));
54    let _ = writer.flush();
55
56    let mut input = String::new();
57    reader.read_line(&mut input).unwrap_or(0);
58    let trimmed = input.trim().to_owned();
59    if trimmed.is_empty() {
60        default.to_owned()
61    } else {
62        trimmed
63    }
64}
65
66/// Prompt for a required field, looping until a non-empty value is entered.
67/// Returns `None` on EOF or IO error so the caller can abort gracefully.
68fn prompt_required<R: BufRead, W: Write>(
69    reader: &mut R,
70    writer: &mut W,
71    label: &str,
72    hint: &str,
73) -> Option<String> {
74    loop {
75        let _ = write!(
76            writer,
77            "{} {}  {}: ",
78            sym_q(),
79            label,
80            sym_dim(&format!("[{hint}]"))
81        );
82        let _ = writer.flush();
83
84        let mut input = String::new();
85        match reader.read_line(&mut input) {
86            Ok(0) | Err(_) => return None,
87            Ok(_) => {}
88        }
89        let trimmed = input.trim().to_owned();
90        if !trimmed.is_empty() {
91            return Some(trimmed);
92        }
93        let _ = writeln!(writer, "  {} {} is required.", sym_fail(), label);
94    }
95}
96
97/// Prompt for a credential field during a profile update. Shows the masked
98/// current value inline; pressing Enter keeps the existing value. Returns
99/// `None` on EOF.
100fn prompt_credential_update<R: BufRead, W: Write>(
101    reader: &mut R,
102    writer: &mut W,
103    label: &str,
104    current: &str,
105) -> Option<String> {
106    let hint = format!("{} (Enter to keep)", output::mask_credential(current));
107    let _ = write!(writer, "{} {}  {}: ", sym_q(), label, sym_dim(&hint));
108    let _ = writer.flush();
109
110    let mut input = String::new();
111    match reader.read_line(&mut input) {
112        Ok(0) | Err(_) => return None,
113        Ok(_) => {}
114    }
115    let trimmed = input.trim().to_owned();
116    Some(if trimmed.is_empty() {
117        current.to_owned()
118    } else {
119        trimmed
120    })
121}
122
123fn prompt_confirm<R: BufRead, W: Write>(
124    reader: &mut R,
125    writer: &mut W,
126    label: &str,
127    default_yes: bool,
128) -> bool {
129    let hint = if default_yes { "Y/n" } else { "y/N" };
130    let _ = write!(writer, "{} {}  [{}]: ", sym_q(), label, sym_dim(hint));
131    let _ = writer.flush();
132
133    let mut input = String::new();
134    reader.read_line(&mut input).unwrap_or(0);
135    match input.trim().to_lowercase().as_str() {
136        "y" | "yes" => true,
137        "n" | "no" => false,
138        _ => default_yes,
139    }
140}
141
142fn print_json_schema(config_path: &Path) {
143    let path_str = config_path.to_string_lossy();
144    let schema = serde_json::json!({
145        "configPath": path_str,
146        "tokenInstructions": {
147            "steps": [
148                "Go to https://marketplace.zoom.us/develop/create",
149                "Click 'Build App', choose 'Server-to-Server OAuth'",
150                "Add the required scopes (see requiredScopes)",
151                "Activate the app",
152                "Copy Account ID, Client ID, and Client Secret from the app credentials page"
153            ]
154        },
155        "requiredCredentials": ["account_id", "client_id", "client_secret"],
156        "requiredScopes": CORE_SCOPES,
157        "optionalScopes": OPTIONAL_SCOPES,
158        "example": {
159            "configFile": path_str,
160            "format": "[default]\naccount_id = \"YOUR_ACCOUNT_ID\"\nclient_id = \"YOUR_CLIENT_ID\"\nclient_secret = \"YOUR_CLIENT_SECRET\""
161        }
162    });
163    println!(
164        "{}",
165        serde_json::to_string_pretty(&schema).expect("serialize")
166    );
167}
168
169fn load_existing_profile_names(config_path: &Path) -> Vec<String> {
170    let content = match std::fs::read_to_string(config_path) {
171        Ok(c) => c,
172        Err(_) => return Vec::new(),
173    };
174    let table: toml::Table = match toml::from_str(&content) {
175        Ok(t) => t,
176        Err(_) => return Vec::new(),
177    };
178    table.keys().cloned().collect()
179}
180
181/// Interactive init flow with injectable IO and validator for testing.
182///
183/// `validate` receives (account_id, client_id, client_secret) and returns
184/// `Some(display_name)` on success or `None` on auth failure.
185///
186/// The flow adapts to context:
187/// - **First-ever setup** (no config file): defaults to "default" profile,
188///   shows OAuth setup URL, prompts credentials.
189/// - **Config exists, no `--profile` flag**: shows existing profiles and asks
190///   whether to update an existing one or add a new one.
191/// - **`--profile` given**: updates that profile if it exists, otherwise adds it.
192pub async fn run_init<R, W, Fut>(
193    reader: &mut R,
194    writer: &mut W,
195    config_path: &Path,
196    profile_arg: Option<&str>,
197    validate: impl Fn(String, String, String) -> Fut,
198) -> Result<(), ApiError>
199where
200    R: BufRead,
201    W: Write,
202    Fut: Future<Output = Option<String>>,
203{
204    let _ = writeln!(writer, "\nzoom-cli");
205    let _ = writeln!(writer, "{SEP}\n");
206
207    let existing_profiles = load_existing_profile_names(config_path);
208    let is_first_setup = existing_profiles.is_empty();
209
210    // Determine the target profile and whether this is an update or a new entry.
211    let (profile_name, is_update) = if let Some(p) = profile_arg {
212        let is_update = existing_profiles.contains(&p.to_owned());
213        (p.to_owned(), is_update)
214    } else if is_first_setup {
215        // First run: silently use "default" — no need to ask.
216        ("default".to_owned(), false)
217    } else {
218        // Config exists: show what we have and ask what to do.
219        if existing_profiles.len() == 1 {
220            let p = &existing_profiles[0];
221            let acct = config::read_profile_credentials(config_path, p)
222                .map(|(a, _, _)| format!("  {}", output::mask_credential(&a)))
223                .unwrap_or_default();
224            let _ = writeln!(writer, "  Profile: {}{}\n", p.bold(), sym_dim(&acct));
225        } else {
226            let _ = writeln!(writer, "  Profiles:");
227            for p in &existing_profiles {
228                let acct = config::read_profile_credentials(config_path, p)
229                    .map(|(a, _, _)| format!("  {}", output::mask_credential(&a)))
230                    .unwrap_or_default();
231                let _ = writeln!(writer, "    {}{}", p, sym_dim(&acct));
232            }
233            let _ = writeln!(writer);
234        }
235
236        let action = prompt_optional(reader, writer, "Action  [update/add]", "update");
237        let _ = writeln!(writer);
238
239        if action.trim().eq_ignore_ascii_case("add") {
240            let Some(name) = prompt_required(reader, writer, "Profile name", "e.g. work") else {
241                let _ = writeln!(writer, "\nAborted.");
242                return Ok(());
243            };
244            (name, false)
245        } else {
246            // update (default)
247            if existing_profiles.len() == 1 {
248                (existing_profiles[0].clone(), true)
249            } else {
250                let options = existing_profiles.join("/");
251                let chosen = prompt_optional(
252                    reader,
253                    writer,
254                    &format!("Profile  [{}]", options),
255                    &existing_profiles[0],
256                );
257                let profile = chosen.trim().to_owned();
258                if !existing_profiles.contains(&profile) {
259                    let _ = writeln!(writer, "\n  {} Unknown profile '{}'.", sym_fail(), profile);
260                    return Ok(());
261                }
262                (profile, true)
263            }
264        }
265    };
266
267    // For new profiles, show where to create the OAuth app — no gate, just context.
268    if !is_update {
269        let _ = writeln!(
270            writer,
271            "  {}",
272            sym_dim("Create a Server-to-Server OAuth app at:")
273        );
274        let _ = writeln!(
275            writer,
276            "  {}",
277            sym_dim(&format!("{OAUTH_URL} → Server-to-Server OAuth"))
278        );
279        let _ = writeln!(writer);
280    }
281
282    // Prompt for credentials.
283    let (account_id, client_id, client_secret) = if is_update {
284        let (cur_acct, cur_cid, cur_csec) =
285            config::read_profile_credentials(config_path, &profile_name)
286                .expect("update mode requires existing credentials");
287        let Some(account_id) = prompt_credential_update(reader, writer, "Account ID", &cur_acct)
288        else {
289            let _ = writeln!(writer, "\nAborted.");
290            return Ok(());
291        };
292        let Some(client_id) = prompt_credential_update(reader, writer, "Client ID", &cur_cid)
293        else {
294            let _ = writeln!(writer, "\nAborted.");
295            return Ok(());
296        };
297        let Some(client_secret) =
298            prompt_credential_update(reader, writer, "Client Secret", &cur_csec)
299        else {
300            let _ = writeln!(writer, "\nAborted.");
301            return Ok(());
302        };
303        (account_id, client_id, client_secret)
304    } else {
305        let Some(account_id) =
306            prompt_required(reader, writer, "Account ID", "from app credentials")
307        else {
308            let _ = writeln!(writer, "\nAborted.");
309            return Ok(());
310        };
311        let Some(client_id) = prompt_required(reader, writer, "Client ID", "from app credentials")
312        else {
313            let _ = writeln!(writer, "\nAborted.");
314            return Ok(());
315        };
316        let Some(client_secret) =
317            prompt_required(reader, writer, "Client Secret", "from app credentials")
318        else {
319            let _ = writeln!(writer, "\nAborted.");
320            return Ok(());
321        };
322        (account_id, client_id, client_secret)
323    };
324
325    // Inline credential verification.
326    let _ = write!(writer, "\n  Verifying credentials...");
327    let _ = writer.flush();
328    let validation = validate(account_id.clone(), client_id.clone(), client_secret.clone()).await;
329
330    let save = match validation {
331        Some(display_name) => {
332            let _ = writeln!(writer, " {} Connected as {}", sym_ok(), display_name.bold());
333            true
334        }
335        None => {
336            let _ = writeln!(writer, " {} Could not validate credentials.", sym_fail());
337            prompt_confirm(reader, writer, "Save anyway?", false)
338        }
339    };
340
341    if !save {
342        let _ = writeln!(writer, "\nAborted. Config not saved.");
343        let _ = writer.flush();
344        return Ok(());
345    }
346
347    config::write_profile(
348        config_path,
349        &profile_name,
350        &account_id,
351        &client_id,
352        &client_secret,
353    )?;
354
355    let pfx = if profile_name == "default" {
356        "zoom".to_owned()
357    } else {
358        format!("zoom --profile {}", profile_name)
359    };
360
361    let _ = writeln!(writer);
362    let _ = writeln!(
363        writer,
364        "  {} Configuration saved to {}",
365        sym_ok(),
366        sym_dim(&config_path.display().to_string()),
367    );
368    let _ = writeln!(writer);
369    let _ = writeln!(writer, "  {}:", "Next steps".bold());
370    let _ = writeln!(
371        writer,
372        "    {}",
373        sym_dim(&format!(
374            "{pfx} meetings list      # list upcoming meetings"
375        ))
376    );
377    let _ = writeln!(
378        writer,
379        "    {}",
380        sym_dim(&format!("{pfx} users list          # list users"))
381    );
382    let _ = writeln!(
383        writer,
384        "    {}",
385        sym_dim(&format!("{pfx} completions zsh    # shell completions"))
386    );
387    let _ = writeln!(writer);
388    let _ = writer.flush();
389
390    Ok(())
391}
392
393/// Entry point from main — uses real stdin/stdout and live API validation.
394pub async fn init(profile_arg: Option<String>) -> Result<(), ApiError> {
395    let config_path = config::config_path();
396
397    if !std::io::stdout().is_terminal() {
398        print_json_schema(&config_path);
399        return Ok(());
400    }
401
402    let stdin = std::io::stdin();
403    let stdout = std::io::stdout();
404    let mut reader = std::io::BufReader::new(stdin.lock());
405    let mut writer = std::io::BufWriter::new(stdout.lock());
406
407    run_init(
408        &mut reader,
409        &mut writer,
410        &config_path,
411        profile_arg.as_deref(),
412        |account_id, client_id, client_secret| async move {
413            let mut client = crate::api::ZoomClient::new(account_id, client_id, client_secret);
414            match client.get_user("me").await {
415                Ok(user) => Some(user.display_name.unwrap_or(user.email)),
416                Err(_) => None,
417            }
418        },
419    )
420    .await
421}
422
423#[cfg(test)]
424mod tests {
425    use std::io::Cursor;
426
427    use tempfile::TempDir;
428
429    use super::*;
430
431    fn fake_path(dir: &TempDir) -> std::path::PathBuf {
432        dir.path().join("config.toml")
433    }
434
435    #[tokio::test]
436    async fn init_writes_config_on_valid_credentials() {
437        let dir = TempDir::new().unwrap();
438        let path = fake_path(&dir);
439
440        // First setup: no action or profile prompts — go straight to credentials.
441        let input = b"test-account-id\ntest-client-id\ntest-client-secret\n";
442        let mut reader = Cursor::new(input.as_ref());
443        let mut writer = Vec::<u8>::new();
444
445        run_init(
446            &mut reader,
447            &mut writer,
448            &path,
449            None,
450            |a, b, c| async move {
451                let _ = (a, b, c);
452                Some("Alice Smith".into())
453            },
454        )
455        .await
456        .unwrap();
457
458        let saved = std::fs::read_to_string(&path).unwrap();
459        assert!(saved.contains("account_id"));
460        assert!(saved.contains("test-account-id"));
461        assert!(saved.contains("test-client-id"));
462        assert!(saved.contains("test-client-secret"));
463    }
464
465    #[tokio::test]
466    async fn init_uses_default_profile_name_when_empty() {
467        let dir = TempDir::new().unwrap();
468        let path = fake_path(&dir);
469
470        // First setup silently defaults to "default" profile.
471        let input = b"test-acct\ntest-cid\ntest-csec\n";
472        let mut reader = Cursor::new(input.as_ref());
473        let mut writer = Vec::<u8>::new();
474
475        run_init(
476            &mut reader,
477            &mut writer,
478            &path,
479            None,
480            |a, b, c| async move {
481                let _ = (a, b, c);
482                Some("Test User".into())
483            },
484        )
485        .await
486        .unwrap();
487
488        let saved = std::fs::read_to_string(&path).unwrap();
489        assert!(
490            saved.contains("[default]"),
491            "should use 'default' profile name"
492        );
493    }
494
495    #[tokio::test]
496    async fn init_with_profile_arg_skips_profile_prompt() {
497        let dir = TempDir::new().unwrap();
498        let path = fake_path(&dir);
499
500        // --profile given: no action prompt, go straight to credentials.
501        let input = b"test-acct\ntest-cid\ntest-csec\n";
502        let mut reader = Cursor::new(input.as_ref());
503        let mut writer = Vec::<u8>::new();
504
505        run_init(
506            &mut reader,
507            &mut writer,
508            &path,
509            Some("work"),
510            |a, b, c| async move {
511                let _ = (a, b, c);
512                Some("Alice".into())
513            },
514        )
515        .await
516        .unwrap();
517
518        let saved = std::fs::read_to_string(&path).unwrap();
519        assert!(saved.contains("[work]"));
520    }
521
522    #[tokio::test]
523    async fn init_aborts_when_validation_fails_and_user_declines_save() {
524        let dir = TempDir::new().unwrap();
525        let path = fake_path(&dir);
526
527        let input = b"test-acct\ntest-cid\ntest-csec\nn\n";
528        let mut reader = Cursor::new(input.as_ref());
529        let mut writer = Vec::<u8>::new();
530
531        run_init(
532            &mut reader,
533            &mut writer,
534            &path,
535            None,
536            |a, b, c| async move {
537                let _ = (a, b, c);
538                None
539            },
540        )
541        .await
542        .unwrap();
543
544        assert!(!path.exists(), "config should not be written after abort");
545    }
546
547    #[tokio::test]
548    async fn init_saves_when_validation_fails_but_user_forces_save() {
549        let dir = TempDir::new().unwrap();
550        let path = fake_path(&dir);
551
552        let input = b"test-acct\ntest-cid\ntest-csec\ny\n";
553        let mut reader = Cursor::new(input.as_ref());
554        let mut writer = Vec::<u8>::new();
555
556        run_init(
557            &mut reader,
558            &mut writer,
559            &path,
560            None,
561            |a, b, c| async move {
562                let _ = (a, b, c);
563                None
564            },
565        )
566        .await
567        .unwrap();
568
569        assert!(
570            path.exists(),
571            "config should be saved when user chooses to save anyway"
572        );
573    }
574
575    #[tokio::test]
576    async fn init_overwrites_existing_profile() {
577        let dir = TempDir::new().unwrap();
578        let path = fake_path(&dir);
579        std::fs::write(
580            &path,
581            "[default]\naccount_id = \"old\"\nclient_id = \"old\"\nclient_secret = \"old\"\n",
582        )
583        .unwrap();
584
585        // Config exists: \n accepts the "update" default at the action prompt,
586        // then new values replace each credential.
587        let input = b"\nnew-account\nnew-client\nnew-secret\n";
588        let mut reader = Cursor::new(input.as_ref());
589        let mut writer = Vec::<u8>::new();
590
591        run_init(
592            &mut reader,
593            &mut writer,
594            &path,
595            None,
596            |a, b, c| async move {
597                let _ = (a, b, c);
598                Some("Alice".into())
599            },
600        )
601        .await
602        .unwrap();
603
604        let saved = std::fs::read_to_string(&path).unwrap();
605        assert!(saved.contains("new-account"));
606        assert!(
607            !saved.contains("\"old\""),
608            "old values should be overwritten"
609        );
610    }
611
612    #[tokio::test]
613    async fn init_update_keeps_fields_when_enter_pressed() {
614        let dir = TempDir::new().unwrap();
615        let path = fake_path(&dir);
616        std::fs::write(
617            &path,
618            "[default]\naccount_id = \"keep-acct\"\nclient_id = \"keep-cid\"\nclient_secret = \"keep-csec\"\n",
619        )
620        .unwrap();
621
622        // \n accepts "update" default at action prompt; subsequent \n's keep
623        // each current credential value unchanged.
624        let input = b"\n\n\n\n";
625        let mut reader = Cursor::new(input.as_ref());
626        let mut writer = Vec::<u8>::new();
627
628        run_init(
629            &mut reader,
630            &mut writer,
631            &path,
632            None,
633            |a, b, c| async move {
634                let _ = (a, b, c);
635                Some("Alice".into())
636            },
637        )
638        .await
639        .unwrap();
640
641        let saved = std::fs::read_to_string(&path).unwrap();
642        assert!(saved.contains("keep-acct"), "kept account_id");
643        assert!(saved.contains("keep-cid"), "kept client_id");
644        assert!(saved.contains("keep-csec"), "kept client_secret");
645    }
646
647    #[tokio::test]
648    async fn init_update_does_not_show_oauth_instructions() {
649        let dir = TempDir::new().unwrap();
650        let path = fake_path(&dir);
651        std::fs::write(
652            &path,
653            "[default]\naccount_id = \"acct\"\nclient_id = \"cid\"\nclient_secret = \"csec\"\n",
654        )
655        .unwrap();
656
657        let input = b"\nnew-acct\nnew-cid\nnew-csec\n";
658        let mut reader = Cursor::new(input.as_ref());
659        let mut writer = Vec::<u8>::new();
660
661        run_init(
662            &mut reader,
663            &mut writer,
664            &path,
665            None,
666            |a, b, c| async move {
667                let _ = (a, b, c);
668                Some("Alice".into())
669            },
670        )
671        .await
672        .unwrap();
673
674        let output = String::from_utf8_lossy(&writer);
675        assert!(
676            !output.contains("marketplace.zoom.us/develop/create"),
677            "update mode must not show OAuth setup instructions"
678        );
679        assert!(
680            output.contains("Profile:"),
681            "should list the existing profile"
682        );
683        assert!(output.contains("Action"), "should show the action prompt");
684        assert!(
685            output.contains("Enter to keep"),
686            "credential prompts should show keep hint"
687        );
688    }
689
690    #[tokio::test]
691    async fn init_adds_new_profile_to_existing_config() {
692        let dir = TempDir::new().unwrap();
693        let path = fake_path(&dir);
694        std::fs::write(
695            &path,
696            "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
697        )
698        .unwrap();
699
700        // --profile work (not existing): goes straight to credentials (new profile flow).
701        let input = b"work-acct\nwork-cid\nwork-csec\n";
702        let mut reader = Cursor::new(input.as_ref());
703        let mut writer = Vec::<u8>::new();
704
705        run_init(
706            &mut reader,
707            &mut writer,
708            &path,
709            Some("work"),
710            |a, b, c| async move {
711                let _ = (a, b, c);
712                Some("Bob".into())
713            },
714        )
715        .await
716        .unwrap();
717
718        let saved = std::fs::read_to_string(&path).unwrap();
719        assert!(saved.contains("[default]"), "default profile preserved");
720        assert!(saved.contains("[work]"), "new profile added");
721        assert!(saved.contains("work-acct"));
722        assert!(saved.contains("def-acct"), "existing credentials untouched");
723
724        let output = String::from_utf8_lossy(&writer);
725        assert!(
726            !output.contains("Press Enter when your app is ready"),
727            "must not show the old wait gate"
728        );
729    }
730
731    #[tokio::test]
732    async fn init_aborts_gracefully_on_eof_during_required_prompt() {
733        let dir = TempDir::new().unwrap();
734        let path = fake_path(&dir);
735
736        // First setup: EOF immediately on the Account ID prompt.
737        let input = b"";
738        let mut reader = Cursor::new(input.as_ref());
739        let mut writer = Vec::<u8>::new();
740
741        run_init(
742            &mut reader,
743            &mut writer,
744            &path,
745            None,
746            |a, b, c| async move {
747                let _ = (a, b, c);
748                Some("Unreachable".into())
749            },
750        )
751        .await
752        .unwrap();
753
754        assert!(
755            !path.exists(),
756            "config must not be written on aborted input"
757        );
758        let output = String::from_utf8_lossy(&writer);
759        assert!(output.contains("Aborted"), "should print an abort message");
760    }
761
762    #[tokio::test]
763    async fn init_outro_includes_profile_flag_for_non_default_profiles() {
764        let dir = TempDir::new().unwrap();
765        let path = fake_path(&dir);
766
767        let input = b"work-acct\nwork-cid\nwork-csec\n";
768        let mut reader = Cursor::new(input.as_ref());
769        let mut writer = Vec::<u8>::new();
770
771        run_init(
772            &mut reader,
773            &mut writer,
774            &path,
775            Some("work"),
776            |a, b, c| async move {
777                let _ = (a, b, c);
778                Some("Bob".into())
779            },
780        )
781        .await
782        .unwrap();
783
784        let output = String::from_utf8_lossy(&writer);
785        assert!(
786            output.contains("--profile work"),
787            "outro should include --profile flag for non-default profiles"
788        );
789    }
790
791    #[tokio::test]
792    async fn init_action_add_prompts_for_new_profile_name() {
793        let dir = TempDir::new().unwrap();
794        let path = fake_path(&dir);
795        std::fs::write(
796            &path,
797            "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
798        )
799        .unwrap();
800
801        // Choose "add" action, then supply a profile name and credentials.
802        let input = b"add\nstaging\nstg-acct\nstg-cid\nstg-csec\n";
803        let mut reader = Cursor::new(input.as_ref());
804        let mut writer = Vec::<u8>::new();
805
806        run_init(
807            &mut reader,
808            &mut writer,
809            &path,
810            None,
811            |a, b, c| async move {
812                let _ = (a, b, c);
813                Some("Carol".into())
814            },
815        )
816        .await
817        .unwrap();
818
819        let saved = std::fs::read_to_string(&path).unwrap();
820        assert!(saved.contains("[default]"), "default profile preserved");
821        assert!(saved.contains("[staging]"), "new profile added");
822        assert!(saved.contains("stg-acct"));
823
824        let output = String::from_utf8_lossy(&writer);
825        assert!(output.contains(OAUTH_URL), "add flow should show OAuth URL");
826    }
827}