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!(writer, "  {}\n", sym_dim(OAUTH_URL));
275    }
276
277    // Prompt for credentials.
278    let (account_id, client_id, client_secret) = if is_update {
279        let (cur_acct, cur_cid, cur_csec) =
280            config::read_profile_credentials(config_path, &profile_name)
281                .expect("update mode requires existing credentials");
282        let Some(account_id) = prompt_credential_update(reader, writer, "Account ID", &cur_acct)
283        else {
284            let _ = writeln!(writer, "\nAborted.");
285            return Ok(());
286        };
287        let Some(client_id) = prompt_credential_update(reader, writer, "Client ID", &cur_cid)
288        else {
289            let _ = writeln!(writer, "\nAborted.");
290            return Ok(());
291        };
292        let Some(client_secret) =
293            prompt_credential_update(reader, writer, "Client Secret", &cur_csec)
294        else {
295            let _ = writeln!(writer, "\nAborted.");
296            return Ok(());
297        };
298        (account_id, client_id, client_secret)
299    } else {
300        let Some(account_id) =
301            prompt_required(reader, writer, "Account ID", "from app credentials")
302        else {
303            let _ = writeln!(writer, "\nAborted.");
304            return Ok(());
305        };
306        let Some(client_id) = prompt_required(reader, writer, "Client ID", "from app credentials")
307        else {
308            let _ = writeln!(writer, "\nAborted.");
309            return Ok(());
310        };
311        let Some(client_secret) =
312            prompt_required(reader, writer, "Client Secret", "from app credentials")
313        else {
314            let _ = writeln!(writer, "\nAborted.");
315            return Ok(());
316        };
317        (account_id, client_id, client_secret)
318    };
319
320    // Inline credential verification.
321    let _ = write!(writer, "\n  Verifying credentials...");
322    let _ = writer.flush();
323    let validation = validate(account_id.clone(), client_id.clone(), client_secret.clone()).await;
324
325    let save = match validation {
326        Some(display_name) => {
327            let _ = writeln!(writer, " {} Connected as {}", sym_ok(), display_name.bold());
328            true
329        }
330        None => {
331            let _ = writeln!(writer, " {} Could not validate credentials.", sym_fail());
332            prompt_confirm(reader, writer, "Save anyway?", false)
333        }
334    };
335
336    if !save {
337        let _ = writeln!(writer, "\nAborted. Config not saved.");
338        let _ = writer.flush();
339        return Ok(());
340    }
341
342    config::write_profile(
343        config_path,
344        &profile_name,
345        &account_id,
346        &client_id,
347        &client_secret,
348    )?;
349
350    let run_cmd = if profile_name == "default" {
351        "zoom users me".to_owned()
352    } else {
353        format!("zoom --profile {} users me", profile_name)
354    };
355
356    let _ = writeln!(writer, "\n{SEP}");
357    let _ = writeln!(
358        writer,
359        "  {} Config saved to {}",
360        sym_ok(),
361        sym_dim(&config_path.display().to_string()),
362    );
363    let _ = writeln!(writer, "  Run: {}", run_cmd.bold());
364    let _ = writer.flush();
365
366    Ok(())
367}
368
369/// Entry point from main — uses real stdin/stdout and live API validation.
370pub async fn init(profile_arg: Option<String>) -> Result<(), ApiError> {
371    let config_path = config::config_path();
372
373    if !std::io::stdout().is_terminal() {
374        print_json_schema(&config_path);
375        return Ok(());
376    }
377
378    let stdin = std::io::stdin();
379    let stdout = std::io::stdout();
380    let mut reader = std::io::BufReader::new(stdin.lock());
381    let mut writer = std::io::BufWriter::new(stdout.lock());
382
383    run_init(
384        &mut reader,
385        &mut writer,
386        &config_path,
387        profile_arg.as_deref(),
388        |account_id, client_id, client_secret| async move {
389            let mut client = crate::api::ZoomClient::new(account_id, client_id, client_secret);
390            match client.get_user("me").await {
391                Ok(user) => Some(user.display_name.unwrap_or(user.email)),
392                Err(_) => None,
393            }
394        },
395    )
396    .await
397}
398
399#[cfg(test)]
400mod tests {
401    use std::io::Cursor;
402
403    use tempfile::TempDir;
404
405    use super::*;
406
407    fn fake_path(dir: &TempDir) -> std::path::PathBuf {
408        dir.path().join("config.toml")
409    }
410
411    #[tokio::test]
412    async fn init_writes_config_on_valid_credentials() {
413        let dir = TempDir::new().unwrap();
414        let path = fake_path(&dir);
415
416        // First setup: no action or profile prompts — go straight to credentials.
417        let input = b"test-account-id\ntest-client-id\ntest-client-secret\n";
418        let mut reader = Cursor::new(input.as_ref());
419        let mut writer = Vec::<u8>::new();
420
421        run_init(
422            &mut reader,
423            &mut writer,
424            &path,
425            None,
426            |a, b, c| async move {
427                let _ = (a, b, c);
428                Some("Alice Smith".into())
429            },
430        )
431        .await
432        .unwrap();
433
434        let saved = std::fs::read_to_string(&path).unwrap();
435        assert!(saved.contains("account_id"));
436        assert!(saved.contains("test-account-id"));
437        assert!(saved.contains("test-client-id"));
438        assert!(saved.contains("test-client-secret"));
439    }
440
441    #[tokio::test]
442    async fn init_uses_default_profile_name_when_empty() {
443        let dir = TempDir::new().unwrap();
444        let path = fake_path(&dir);
445
446        // First setup silently defaults to "default" profile.
447        let input = b"test-acct\ntest-cid\ntest-csec\n";
448        let mut reader = Cursor::new(input.as_ref());
449        let mut writer = Vec::<u8>::new();
450
451        run_init(
452            &mut reader,
453            &mut writer,
454            &path,
455            None,
456            |a, b, c| async move {
457                let _ = (a, b, c);
458                Some("Test User".into())
459            },
460        )
461        .await
462        .unwrap();
463
464        let saved = std::fs::read_to_string(&path).unwrap();
465        assert!(
466            saved.contains("[default]"),
467            "should use 'default' profile name"
468        );
469    }
470
471    #[tokio::test]
472    async fn init_with_profile_arg_skips_profile_prompt() {
473        let dir = TempDir::new().unwrap();
474        let path = fake_path(&dir);
475
476        // --profile given: no action prompt, go straight to credentials.
477        let input = b"test-acct\ntest-cid\ntest-csec\n";
478        let mut reader = Cursor::new(input.as_ref());
479        let mut writer = Vec::<u8>::new();
480
481        run_init(
482            &mut reader,
483            &mut writer,
484            &path,
485            Some("work"),
486            |a, b, c| async move {
487                let _ = (a, b, c);
488                Some("Alice".into())
489            },
490        )
491        .await
492        .unwrap();
493
494        let saved = std::fs::read_to_string(&path).unwrap();
495        assert!(saved.contains("[work]"));
496    }
497
498    #[tokio::test]
499    async fn init_aborts_when_validation_fails_and_user_declines_save() {
500        let dir = TempDir::new().unwrap();
501        let path = fake_path(&dir);
502
503        let input = b"test-acct\ntest-cid\ntest-csec\nn\n";
504        let mut reader = Cursor::new(input.as_ref());
505        let mut writer = Vec::<u8>::new();
506
507        run_init(
508            &mut reader,
509            &mut writer,
510            &path,
511            None,
512            |a, b, c| async move {
513                let _ = (a, b, c);
514                None
515            },
516        )
517        .await
518        .unwrap();
519
520        assert!(!path.exists(), "config should not be written after abort");
521    }
522
523    #[tokio::test]
524    async fn init_saves_when_validation_fails_but_user_forces_save() {
525        let dir = TempDir::new().unwrap();
526        let path = fake_path(&dir);
527
528        let input = b"test-acct\ntest-cid\ntest-csec\ny\n";
529        let mut reader = Cursor::new(input.as_ref());
530        let mut writer = Vec::<u8>::new();
531
532        run_init(
533            &mut reader,
534            &mut writer,
535            &path,
536            None,
537            |a, b, c| async move {
538                let _ = (a, b, c);
539                None
540            },
541        )
542        .await
543        .unwrap();
544
545        assert!(
546            path.exists(),
547            "config should be saved when user chooses to save anyway"
548        );
549    }
550
551    #[tokio::test]
552    async fn init_overwrites_existing_profile() {
553        let dir = TempDir::new().unwrap();
554        let path = fake_path(&dir);
555        std::fs::write(
556            &path,
557            "[default]\naccount_id = \"old\"\nclient_id = \"old\"\nclient_secret = \"old\"\n",
558        )
559        .unwrap();
560
561        // Config exists: \n accepts the "update" default at the action prompt,
562        // then new values replace each credential.
563        let input = b"\nnew-account\nnew-client\nnew-secret\n";
564        let mut reader = Cursor::new(input.as_ref());
565        let mut writer = Vec::<u8>::new();
566
567        run_init(
568            &mut reader,
569            &mut writer,
570            &path,
571            None,
572            |a, b, c| async move {
573                let _ = (a, b, c);
574                Some("Alice".into())
575            },
576        )
577        .await
578        .unwrap();
579
580        let saved = std::fs::read_to_string(&path).unwrap();
581        assert!(saved.contains("new-account"));
582        assert!(
583            !saved.contains("\"old\""),
584            "old values should be overwritten"
585        );
586    }
587
588    #[tokio::test]
589    async fn init_update_keeps_fields_when_enter_pressed() {
590        let dir = TempDir::new().unwrap();
591        let path = fake_path(&dir);
592        std::fs::write(
593            &path,
594            "[default]\naccount_id = \"keep-acct\"\nclient_id = \"keep-cid\"\nclient_secret = \"keep-csec\"\n",
595        )
596        .unwrap();
597
598        // \n accepts "update" default at action prompt; subsequent \n's keep
599        // each current credential value unchanged.
600        let input = b"\n\n\n\n";
601        let mut reader = Cursor::new(input.as_ref());
602        let mut writer = Vec::<u8>::new();
603
604        run_init(
605            &mut reader,
606            &mut writer,
607            &path,
608            None,
609            |a, b, c| async move {
610                let _ = (a, b, c);
611                Some("Alice".into())
612            },
613        )
614        .await
615        .unwrap();
616
617        let saved = std::fs::read_to_string(&path).unwrap();
618        assert!(saved.contains("keep-acct"), "kept account_id");
619        assert!(saved.contains("keep-cid"), "kept client_id");
620        assert!(saved.contains("keep-csec"), "kept client_secret");
621    }
622
623    #[tokio::test]
624    async fn init_update_does_not_show_oauth_instructions() {
625        let dir = TempDir::new().unwrap();
626        let path = fake_path(&dir);
627        std::fs::write(
628            &path,
629            "[default]\naccount_id = \"acct\"\nclient_id = \"cid\"\nclient_secret = \"csec\"\n",
630        )
631        .unwrap();
632
633        let input = b"\nnew-acct\nnew-cid\nnew-csec\n";
634        let mut reader = Cursor::new(input.as_ref());
635        let mut writer = Vec::<u8>::new();
636
637        run_init(
638            &mut reader,
639            &mut writer,
640            &path,
641            None,
642            |a, b, c| async move {
643                let _ = (a, b, c);
644                Some("Alice".into())
645            },
646        )
647        .await
648        .unwrap();
649
650        let output = String::from_utf8_lossy(&writer);
651        assert!(
652            !output.contains("marketplace.zoom.us/develop/create"),
653            "update mode must not show OAuth setup instructions"
654        );
655        assert!(
656            output.contains("Profile:"),
657            "should list the existing profile"
658        );
659        assert!(output.contains("Action"), "should show the action prompt");
660        assert!(
661            output.contains("Enter to keep"),
662            "credential prompts should show keep hint"
663        );
664    }
665
666    #[tokio::test]
667    async fn init_adds_new_profile_to_existing_config() {
668        let dir = TempDir::new().unwrap();
669        let path = fake_path(&dir);
670        std::fs::write(
671            &path,
672            "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
673        )
674        .unwrap();
675
676        // --profile work (not existing): goes straight to credentials (new profile flow).
677        let input = b"work-acct\nwork-cid\nwork-csec\n";
678        let mut reader = Cursor::new(input.as_ref());
679        let mut writer = Vec::<u8>::new();
680
681        run_init(
682            &mut reader,
683            &mut writer,
684            &path,
685            Some("work"),
686            |a, b, c| async move {
687                let _ = (a, b, c);
688                Some("Bob".into())
689            },
690        )
691        .await
692        .unwrap();
693
694        let saved = std::fs::read_to_string(&path).unwrap();
695        assert!(saved.contains("[default]"), "default profile preserved");
696        assert!(saved.contains("[work]"), "new profile added");
697        assert!(saved.contains("work-acct"));
698        assert!(saved.contains("def-acct"), "existing credentials untouched");
699
700        let output = String::from_utf8_lossy(&writer);
701        assert!(
702            !output.contains("Press Enter when your app is ready"),
703            "must not show the old wait gate"
704        );
705    }
706
707    #[tokio::test]
708    async fn init_aborts_gracefully_on_eof_during_required_prompt() {
709        let dir = TempDir::new().unwrap();
710        let path = fake_path(&dir);
711
712        // First setup: EOF immediately on the Account ID prompt.
713        let input = b"";
714        let mut reader = Cursor::new(input.as_ref());
715        let mut writer = Vec::<u8>::new();
716
717        run_init(
718            &mut reader,
719            &mut writer,
720            &path,
721            None,
722            |a, b, c| async move {
723                let _ = (a, b, c);
724                Some("Unreachable".into())
725            },
726        )
727        .await
728        .unwrap();
729
730        assert!(
731            !path.exists(),
732            "config must not be written on aborted input"
733        );
734        let output = String::from_utf8_lossy(&writer);
735        assert!(output.contains("Aborted"), "should print an abort message");
736    }
737
738    #[tokio::test]
739    async fn init_outro_includes_profile_flag_for_non_default_profiles() {
740        let dir = TempDir::new().unwrap();
741        let path = fake_path(&dir);
742
743        let input = b"work-acct\nwork-cid\nwork-csec\n";
744        let mut reader = Cursor::new(input.as_ref());
745        let mut writer = Vec::<u8>::new();
746
747        run_init(
748            &mut reader,
749            &mut writer,
750            &path,
751            Some("work"),
752            |a, b, c| async move {
753                let _ = (a, b, c);
754                Some("Bob".into())
755            },
756        )
757        .await
758        .unwrap();
759
760        let output = String::from_utf8_lossy(&writer);
761        assert!(
762            output.contains("--profile work"),
763            "outro should include --profile flag for non-default profiles"
764        );
765    }
766
767    #[tokio::test]
768    async fn init_action_add_prompts_for_new_profile_name() {
769        let dir = TempDir::new().unwrap();
770        let path = fake_path(&dir);
771        std::fs::write(
772            &path,
773            "[default]\naccount_id = \"def-acct\"\nclient_id = \"def-cid\"\nclient_secret = \"def-csec\"\n",
774        )
775        .unwrap();
776
777        // Choose "add" action, then supply a profile name and credentials.
778        let input = b"add\nstaging\nstg-acct\nstg-cid\nstg-csec\n";
779        let mut reader = Cursor::new(input.as_ref());
780        let mut writer = Vec::<u8>::new();
781
782        run_init(
783            &mut reader,
784            &mut writer,
785            &path,
786            None,
787            |a, b, c| async move {
788                let _ = (a, b, c);
789                Some("Carol".into())
790            },
791        )
792        .await
793        .unwrap();
794
795        let saved = std::fs::read_to_string(&path).unwrap();
796        assert!(saved.contains("[default]"), "default profile preserved");
797        assert!(saved.contains("[staging]"), "new profile added");
798        assert!(saved.contains("stg-acct"));
799
800        let output = String::from_utf8_lossy(&writer);
801        assert!(output.contains(OAUTH_URL), "add flow should show OAuth URL");
802    }
803}