Skip to main content

teams_cli/
setup.rs

1//! Interactive setup selects user intent; explicit CLI flags keep their existing contract.
2use std::collections::BTreeMap;
3use std::io::{BufRead, Write};
4use std::net::{Ipv4Addr, Ipv6Addr, TcpListener};
5
6use crate::cli::InitArgs;
7use crate::config::{BackendKind, DesktopAccount, DesktopClientKind, Profile};
8use crate::error::AppError;
9
10mod capabilities;
11pub use capabilities::{Availability, Capabilities, graph_device_code_needed};
12
13pub struct Plan {
14    pub name: String,
15    pub args: InitArgs,
16    pub browser_confirmed: bool,
17    pub forget_pending: Option<crate::config::PendingBrowserSetup>,
18}
19
20pub fn offered(args: &InitArgs) -> bool {
21    args.backend.is_none()
22        && args.desktop_port.is_none()
23        && args.desktop_account.is_none()
24        && args.desktop_client.is_none()
25        && args.credential_store.is_none()
26        && args.pass_store_dir.is_none()
27        && args.client_id.is_none()
28        && args.tenant.is_none()
29        && !args.no_login
30        && !args.device_code
31        && !args.channel_history
32        && !args.auto_start
33        && !(args.allow_send && args.read_only)
34}
35
36fn choice(
37    input: &mut impl BufRead,
38    output: &mut impl Write,
39    prompt: &str,
40    default: &str,
41    allowed: &[&str],
42) -> Result<Option<String>, AppError> {
43    loop {
44        write!(output, "{prompt} [{default}]: ")?;
45        output.flush()?;
46        let mut answer = String::new();
47        if input.read_line(&mut answer)? == 0 {
48            return Ok(None);
49        }
50        let answer = answer.trim().to_ascii_lowercase();
51        if matches!(answer.as_str(), "q" | "quit") {
52            return Ok(None);
53        }
54        let answer = if answer.is_empty() { default } else { &answer };
55        if allowed.contains(&answer) {
56            return Ok(Some(answer.into()));
57        }
58        writeln!(output, "Choose {}, or q to cancel.", allowed.join(" / "))?;
59    }
60}
61
62fn confirm(
63    input: &mut impl BufRead,
64    output: &mut impl Write,
65    prompt: &str,
66) -> Result<bool, AppError> {
67    Ok(
68        choice(input, output, prompt, "n", &["y", "yes", "n", "no"])?
69            .is_some_and(|answer| matches!(answer.as_str(), "y" | "yes")),
70    )
71}
72
73/// Offer an initialized pass store only for new Graph profiles. Existing choices
74/// are preserved by the caller, including when their helper is unavailable.
75pub fn choose_credential_store(
76    native_label: &str,
77    native_error: Option<&str>,
78    input: &mut impl BufRead,
79    output: &mut impl Write,
80) -> Result<Option<crate::config::CredentialStore>, AppError> {
81    writeln!(output, "\nProtect your sign-in")?;
82    if let Some(reason) = native_error {
83        writeln!(
84            output,
85            "  {native_label} is unavailable: {reason}\n  2  pass — your GPG-encrypted password store (recommended)\n"
86        )?;
87    } else {
88        writeln!(
89            output,
90            "  1  {native_label} (recommended)\n  2  pass — your GPG-encrypted password store\n"
91        )?;
92    }
93    writeln!(
94        output,
95        "pass may ask you to unlock your GPG key during sign-in or later reads."
96    )?;
97    let (default, allowed): (&str, &[&str]) = if native_error.is_some() {
98        ("2", &["2"])
99    } else {
100        ("1", &["1", "2"])
101    };
102    Ok(
103        choice(input, output, "Credential store", default, allowed)?.map(|answer| {
104            if answer == "2" {
105                crate::config::CredentialStore::Pass
106            } else {
107                crate::config::CredentialStore::Native
108            }
109        }),
110    )
111}
112
113pub fn read_only_fallback(
114    reason: &str,
115    input: &mut impl BufRead,
116    output: &mut impl Write,
117) -> Result<bool, AppError> {
118    writeln!(
119        output,
120        "\nYour chats are readable, but sending is unavailable.\n{reason}\n"
121    )?;
122    confirm(input, output, "Continue with read-only access")
123}
124
125/// Unavailable choices have no selectable number. Typed disabled keys explain why.
126fn menu(
127    input: &mut impl BufRead,
128    output: &mut impl Write,
129    prompt: &str,
130    items: &[(&str, &str, Option<&str>)],
131) -> Result<Option<String>, AppError> {
132    writeln!(output)?;
133    for (key, label, blocked) in items {
134        match blocked {
135            None => writeln!(output, "  {key}  {label}")?,
136            Some(reason) => writeln!(output, "     {label} — unavailable\n     {reason}")?,
137        }
138    }
139    writeln!(output)?;
140    let Some((default, _, _)) = items.iter().find(|(_, _, blocked)| blocked.is_none()) else {
141        return Err(AppError::Unsupported("No connection is available with these options. Resolve the requirements above and run `teams init` again.".into()));
142    };
143    loop {
144        write!(output, "{prompt} [{default}]: ")?;
145        output.flush()?;
146        let mut answer = String::new();
147        if input.read_line(&mut answer)? == 0 {
148            return Ok(None);
149        }
150        let answer = answer.trim().to_ascii_lowercase();
151        if matches!(answer.as_str(), "q" | "quit") {
152            return Ok(None);
153        }
154        let answer = if answer.is_empty() { *default } else { &answer };
155        if let Some((key, label, blocked)) = items.iter().find(|(key, _, _)| *key == answer) {
156            if let Some(reason) = blocked {
157                writeln!(output, "{label} is unavailable: {reason}")?;
158            } else {
159                return Ok(Some((*key).into()));
160            }
161        } else {
162            writeln!(
163                output,
164                "Enter one of the available numbers, or q to cancel."
165            )?;
166        }
167    }
168}
169
170fn compatible(
171    profile: &Profile,
172    backend: BackendKind,
173    kind: DesktopClientKind,
174    account: DesktopAccount,
175) -> bool {
176    profile.backend == backend
177        && (backend == BackendKind::Graph
178            || (profile.desktop_client == kind && profile.desktop_account == account))
179}
180
181fn profile_name(
182    requested: Option<&str>,
183    profiles: &BTreeMap<String, Profile>,
184    backend: BackendKind,
185    kind: DesktopClientKind,
186    account: DesktopAccount,
187) -> String {
188    if let Some(name) = requested {
189        return name.into();
190    }
191    let base = if backend == BackendKind::Graph {
192        "graph"
193    } else if account == DesktopAccount::WorkSchool {
194        "work"
195    } else {
196        "personal"
197    };
198    if !profiles.contains_key(base)
199        && let Some((name, _)) = profiles
200            .iter()
201            .find(|(_, profile)| compatible(profile, backend, kind, account))
202    {
203        return name.clone();
204    }
205    for number in 1.. {
206        let name = if number == 1 {
207            base.into()
208        } else {
209            format!("{base}-{number}")
210        };
211        if profiles
212            .get(&name)
213            .is_none_or(|profile| compatible(profile, backend, kind, account))
214        {
215            return name;
216        }
217    }
218    unreachable!()
219}
220
221fn fresh_name(
222    base: &str,
223    profiles: &BTreeMap<String, Profile>,
224    pending: &[crate::config::PendingBrowserSetup],
225) -> String {
226    for number in 1.. {
227        let name = if number == 1 {
228            base.into()
229        } else {
230            format!("{base}-{number}")
231        };
232        if !profiles.contains_key(&name) && !pending.iter().any(|record| record.name == name) {
233            return name;
234        }
235    }
236    unreachable!()
237}
238
239pub fn available_port(port: u16) -> Result<bool, AppError> {
240    let Ok(_ipv4) = TcpListener::bind((Ipv4Addr::LOCALHOST, port)) else {
241        return Ok(false);
242    };
243    match TcpListener::bind((Ipv6Addr::LOCALHOST, port)) {
244        Ok(_ipv6) => {}
245        Err(error)
246            if matches!(
247                error.kind(),
248                std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
249            ) => {}
250        Err(_) => return Ok(false),
251    }
252    #[cfg(any(target_os = "macos", target_os = "linux"))]
253    if crate::desktop::browser::has_saved_data(port)? {
254        return Ok(false);
255    }
256    Ok(true)
257}
258
259pub fn run(
260    mut args: InitArgs,
261    capabilities: &Capabilities,
262    requested: Option<&str>,
263    profiles: &BTreeMap<String, Profile>,
264    input: &mut impl BufRead,
265    output: &mut impl Write,
266    mut available: impl FnMut(u16) -> Result<bool, AppError>,
267) -> Result<Option<Plan>, AppError> {
268    writeln!(
269        output,
270        "\nTeams\n\nConnect your account. Enter q at any prompt to cancel."
271    )?;
272    let graph_blocked = args.allow_send.then_some("--allow-send applies to Teams Desktop and browser connections. Run `teams init` without this flag to use Microsoft Graph.")
273        .or_else(|| capabilities.graph.reason());
274    let work_blocked = if capabilities.preferred(false).is_none() {
275        graph_blocked
276    } else {
277        None
278    };
279    let personal_blocked = capabilities.preferred(true).is_none().then_some(
280        "Personal needs a Teams browser or Teams Desktop. See Other connection options for requirements.");
281    let Some(account) = menu(
282        input,
283        output,
284        "Account",
285        &[
286            ("1", "Work or school", work_blocked),
287            ("2", "Personal", personal_blocked),
288            ("3", "Other connection options", None),
289        ],
290    )?
291    else {
292        return Ok(None);
293    };
294    let mut backend = BackendKind::Desktop;
295    let mut kind = capabilities.preferred(account == "2").unwrap_or_else(|| {
296        backend = BackendKind::Graph;
297        DesktopClientKind::Native
298    });
299    let mut account = if account == "2" {
300        DesktopAccount::Personal
301    } else if account == "1" {
302        DesktopAccount::WorkSchool
303    } else {
304        let Some(method) = menu(
305            input,
306            output,
307            "Connection",
308            &[
309                (
310                    "1",
311                    "Microsoft Graph — work/school, including teams and channels",
312                    graph_blocked,
313                ),
314                (
315                    "2",
316                    "Teams Desktop — use the account signed in to the app",
317                    capabilities.native.reason(),
318                ),
319                (
320                    "3",
321                    "Full browser — personal account",
322                    capabilities.browser.reason(),
323                ),
324                (
325                    "4",
326                    "Lightweight browser — personal account (experimental)",
327                    capabilities.core.reason(),
328                ),
329            ],
330        )?
331        else {
332            return Ok(None);
333        };
334        match method.as_str() {
335            "1" => {
336                backend = BackendKind::Graph;
337                DesktopAccount::WorkSchool
338            }
339            "2" => {
340                backend = BackendKind::Desktop;
341                kind = DesktopClientKind::Native;
342                writeln!(output, "\n  1  Work or school\n  2  Personal\n")?;
343                let Some(account) =
344                    choice(input, output, "Teams Desktop account", "1", &["1", "2"])?
345                else {
346                    return Ok(None);
347                };
348                if account == "1" {
349                    DesktopAccount::WorkSchool
350                } else {
351                    DesktopAccount::Personal
352                }
353            }
354            _ => {
355                backend = BackendKind::Desktop;
356                kind = if method == "4" {
357                    DesktopClientKind::BrowserCore
358                } else {
359                    DesktopClientKind::Browser
360                };
361                DesktopAccount::Personal
362            }
363        }
364    };
365    if backend == BackendKind::Graph {
366        kind = DesktopClientKind::Native;
367        account = DesktopAccount::Auto;
368        args.device_code = capabilities.graph_device_code;
369        writeln!(output, "\nUsing Microsoft Graph for work or school.")?;
370        if args.device_code {
371            writeln!(
372                output,
373                "Sign in with a code on another device; no local browser is needed."
374            )?;
375        }
376    } else {
377        writeln!(
378            output,
379            "\nUsing {}.",
380            match kind {
381                DesktopClientKind::Browser => "a separate Teams browser",
382                DesktopClientKind::BrowserCore => "the lightweight Teams browser (experimental)",
383                DesktopClientKind::Native => "Teams Desktop",
384            }
385        )?;
386        if account == DesktopAccount::Personal
387            && kind != DesktopClientKind::BrowserCore
388            && let Some(reason) = capabilities.core.reason()
389        {
390            writeln!(output, "Lightweight browser unavailable: {reason}")?;
391        }
392        if matches!(
393            capabilities.availability(kind),
394            Availability::Ready { download: true }
395        ) {
396            writeln!(
397                output,
398                "A matching browser pair will download from Google after you confirm.\nAllow a few GiB of free space for installation and saved browser data."
399            )?;
400        }
401    }
402    let base = if backend == BackendKind::Graph {
403        "graph"
404    } else if account == DesktopAccount::WorkSchool {
405        "work"
406    } else {
407        "personal"
408    };
409    let mut name = profile_name(requested, profiles, backend, kind, account);
410    if profiles
411        .get(&name)
412        .is_some_and(|profile| compatible(profile, backend, kind, account))
413    {
414        if requested.is_some() {
415            if !confirm(
416                input,
417                output,
418                &format!(
419                    "{} '{}'",
420                    if backend == BackendKind::Graph {
421                        "Sign in again and update"
422                    } else {
423                        "Update connection using the saved sign-in for"
424                    },
425                    name.escape_default(),
426                ),
427            )? {
428                return Ok(None);
429            }
430        } else {
431            let update = if backend == BackendKind::Graph {
432                format!("Sign in again to update '{}'", name.escape_default())
433            } else {
434                format!("Update '{}' using its saved sign-in", name.escape_default())
435            };
436            let new = if backend == BackendKind::Desktop && kind == DesktopClientKind::Native {
437                "Create a separate profile for Teams Desktop"
438            } else {
439                "Connect another account"
440            };
441            let Some(action) = menu(
442                input,
443                output,
444                "Connection",
445                &[("1", new, None), ("2", &update, None)],
446            )?
447            else {
448                return Ok(None);
449            };
450            if action == "1" {
451                name = fresh_name(base, profiles, &capabilities.pending);
452            }
453        }
454    }
455    let mut pending_port = None;
456    let mut forget_pending = None;
457    if backend == BackendKind::Desktop && kind.is_browser() && !profiles.contains_key(&name) {
458        let matches: Vec<_> = capabilities
459            .pending
460            .iter()
461            .filter(|record| {
462                requested.is_none_or(|_| record.name == name)
463                    && !profiles.contains_key(&record.name)
464                    && record.account == account
465                    && record.client == kind
466            })
467            .collect();
468        if requested.is_some() && matches.len() > 1 {
469            return Err(AppError::Desktop("More than one unfinished setup matches this connection. Use `teams init --profile NEW_NAME` for a separate sign-in; existing browsers were preserved.".into()));
470        }
471        let pending = if matches.len() > 1 {
472            let mut choices: Vec<_> = matches
473                .iter()
474                .enumerate()
475                .map(|(i, record)| {
476                    (
477                        (i + 1).to_string(),
478                        format!(
479                            "Continue unfinished '{}' (port {})",
480                            record.name.escape_default(),
481                            record.port
482                        ),
483                    )
484                })
485                .collect();
486            choices.push((
487                (choices.len() + 1).to_string(),
488                "Start a new sign-in".into(),
489            ));
490            let items: Vec<_> = choices
491                .iter()
492                .map(|(key, label)| (key.as_str(), label.as_str(), None))
493                .collect();
494            let Some(selected) = menu(input, output, "Saved sign-ins", &items)? else {
495                return Ok(None);
496            };
497            matches.get(selected.parse::<usize>().unwrap() - 1).copied()
498        } else {
499            matches.first().copied()
500        };
501        if let Some(pending) = pending {
502            name = pending.name.clone();
503            writeln!(
504                output,
505                "\nUnfinished connection: '{}'",
506                name.escape_default()
507            )?;
508            let Some(action) = menu(
509                input,
510                output,
511                "Unfinished setup",
512                &[
513                    ("1", "Resume saved sign-in", None),
514                    (
515                        "2",
516                        "Connect another account",
517                        requested.map(
518                            |_| "Use `teams init --profile NEW_NAME` for a separate connection.",
519                        ),
520                    ),
521                    ("3", "Forget this unfinished setup and start again", None),
522                ],
523            )?
524            else {
525                return Ok(None);
526            };
527            if action == "1" {
528                pending_port = Some(pending.port);
529            } else if action == "3" {
530                writeln!(
531                    output,
532                    "Only the unfinished-setup record will be removed after you confirm browser startup. Its browser data and saved sign-in will be kept."
533                )?;
534                if !confirm(input, output, "Forget this unfinished setup")? {
535                    return Ok(None);
536                }
537                forget_pending = Some(pending.clone());
538            } else if requested.is_some() {
539                return Ok(None);
540            } else {
541                name = fresh_name(base, profiles, &capabilities.pending);
542            }
543        }
544    }
545    let existing = profiles.get(&name);
546    if existing.is_some_and(|profile| !compatible(profile, backend, kind, account))
547        && !confirm(
548            input,
549            output,
550            &format!(
551                "Replace the connection settings for profile '{}'",
552                name.escape_default()
553            ),
554        )?
555    {
556        return Ok(None);
557    }
558    args.backend = Some(backend);
559    if backend == BackendKind::Graph
560        && let Some(existing) = existing.filter(|profile| profile.backend == BackendKind::Graph)
561    {
562        args.client_id = Some(existing.client_id.clone());
563        args.tenant = Some(existing.tenant.clone());
564    }
565    if backend == BackendKind::Desktop {
566        args.desktop_client = Some(kind);
567        args.desktop_account = Some(account);
568        args.desktop_port = Some(
569            if let Some(profile) = existing.filter(|p| compatible(p, backend, kind, account)) {
570                profile.desktop_port
571            } else if let Some(port) = pending_port {
572                port
573            } else if kind.is_browser() {
574                let preferred = if account == DesktopAccount::WorkSchool {
575                    9224
576                } else if kind == DesktopClientKind::BrowserCore {
577                    9223
578                } else {
579                    9225
580                };
581                let mut candidates = std::iter::once(preferred).chain(19230..19330);
582                loop {
583                    let Some(port) = candidates.next() else {
584                        return Err(AppError::Desktop("No free browser port was found. Use advanced setup with --desktop-port.".into()));
585                    };
586                    if !profiles.values().any(|profile| {
587                        profile.backend == BackendKind::Desktop && profile.desktop_port == port
588                    }) && !capabilities
589                        .pending
590                        .iter()
591                        .any(|record| record.port == port)
592                        && available(port)?
593                    {
594                        break port;
595                    }
596                }
597            } else {
598                crate::config::DEFAULT_DESKTOP_PORT
599            },
600        );
601    }
602    if !args.read_only && !args.allow_send {
603        writeln!(output, "\n  1  Read and send messages\n  2  Read only\n")?;
604        let Some(access) = choice(input, output, "Message access", "2", &["1", "2"])? else {
605            return Ok(None);
606        };
607        args.read_only = access == "2";
608        args.allow_send = access == "1" && backend == BackendKind::Desktop;
609    }
610    let browser_confirmed = backend == BackendKind::Desktop && kind.is_browser();
611    if browser_confirmed {
612        writeln!(
613            output,
614            "\nA separate Teams browser will open for sign-in. After setup, `teams` reopens\nyour saved session and inbox. Your usual browser and Teams app stay separate.\nSome organisations need the browser window to stay open.\n\nWhile connected, other programs running as you can access this Teams session,\nincluding reading and sending messages. `teams desktop disconnect` closes it.\n"
615        )?;
616        if !confirm(
617            input,
618            output,
619            "Open Teams and enable this startup behaviour",
620        )? {
621            return Ok(None);
622        }
623        args.auto_start = true;
624    } else if backend == BackendKind::Desktop {
625        writeln!(output, "\nUsing the account signed in to Teams Desktop.\n")?;
626        #[cfg(target_os = "windows")]
627        writeln!(output, "For browser mode, run `teams init` inside WSL2.\n")?;
628    }
629    Ok(Some(Plan {
630        name,
631        args,
632        browser_confirmed,
633        forget_pending,
634    }))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use crate::cli::{Cli, Command};
641    use clap::Parser;
642
643    fn args(flags: &[&str]) -> InitArgs {
644        let cli = Cli::try_parse_from(["teams", "init"].into_iter().chain(flags.iter().copied()))
645            .unwrap();
646        let Some(Command::Init(args)) = cli.command else {
647            panic!("init")
648        };
649        args
650    }
651
652    fn capabilities() -> Capabilities {
653        Capabilities {
654            graph: Availability::Ready { download: false },
655            pending: Vec::new(),
656            browser: Availability::Ready { download: false },
657            core: Availability::Ready { download: false },
658            native: Availability::Ready { download: false },
659            graph_device_code: false,
660        }
661    }
662
663    fn profile(kind: DesktopClientKind, account: DesktopAccount, port: u16) -> Profile {
664        Profile {
665            credential_store: Default::default(),
666            pass_store_dir: None,
667            backend: BackendKind::Desktop,
668            client_id: String::new(),
669            tenant: String::new(),
670            read_only: true,
671            allow_send: false,
672            desktop_port: port,
673            desktop_client: kind,
674            desktop_account: account,
675            browser_auto_start: false,
676        }
677    }
678
679    #[test]
680    fn credential_choice_defaults_to_native_and_cancel_never_selects_a_store() {
681        use crate::config::CredentialStore;
682        for (answer, expected) in [
683            ("\n", Some(CredentialStore::Native)),
684            ("2\n", Some(CredentialStore::Pass)),
685            ("q\n", None),
686            ("", None),
687        ] {
688            let mut output = Vec::new();
689            let selected = super::choose_credential_store(
690                "Windows DPAPI",
691                None,
692                &mut std::io::Cursor::new(answer),
693                &mut output,
694            )
695            .unwrap();
696            assert_eq!(selected, expected);
697            assert!(
698                String::from_utf8(output)
699                    .unwrap()
700                    .contains("unlock your GPG key")
701            );
702        }
703    }
704
705    #[test]
706    fn unavailable_native_storage_cannot_be_selected() {
707        let mut output = Vec::new();
708        let selected = super::choose_credential_store(
709            "Linux Secret Service",
710            Some("no D-Bus session"),
711            &mut std::io::Cursor::new("1\n\n"),
712            &mut output,
713        )
714        .unwrap();
715        assert_eq!(selected, Some(crate::config::CredentialStore::Pass));
716        assert!(
717            String::from_utf8(output)
718                .unwrap()
719                .contains("no D-Bus session")
720        );
721    }
722
723    #[test]
724    fn explicit_connection_flags_and_offline_setup_skip_the_wizard() {
725        assert!(offered(&args(&[])));
726        assert!(offered(&args(&["--read-only"])));
727        for flags in [
728            vec!["--backend", "graph"],
729            vec!["--backend", "desktop"],
730            vec!["--desktop-client", "browser"],
731            vec!["--client-id", "example"],
732            vec!["--no-login"],
733            vec!["--device-code"],
734            vec!["--tenant", "organizations"],
735        ] {
736            assert!(!offered(&args(&flags)), "{flags:?}");
737        }
738    }
739
740    #[test]
741    fn work_setup_chooses_its_browser_and_avoids_reserved_ports_and_old_data() {
742        let profiles = BTreeMap::from([(
743            "existing".into(),
744            profile(DesktopClientKind::Native, DesktopAccount::Personal, 9224),
745        )]);
746        let mut checked = vec![];
747        let mut output = vec![];
748        let plan = run(
749            args(&[]),
750            &capabilities(),
751            None,
752            &profiles,
753            &mut "1\n2\ny\n".as_bytes(),
754            &mut output,
755            |port| {
756                checked.push(port);
757                Ok(port != 19230)
758            },
759        )
760        .unwrap()
761        .unwrap();
762        assert_eq!(plan.name, "work");
763        assert_eq!(plan.args.desktop_account, Some(DesktopAccount::WorkSchool));
764        assert_eq!(plan.args.desktop_client, Some(DesktopClientKind::Browser));
765        assert_eq!(plan.args.desktop_port, Some(19231));
766        assert!(!plan.args.allow_send);
767        assert!(plan.args.auto_start && plan.browser_confirmed);
768        assert_eq!(checked, [19230, 19231]);
769        let text = String::from_utf8(output).unwrap();
770        assert!(text.contains("including reading and sending"));
771        assert!(!text.contains("--desktop-"));
772    }
773
774    #[test]
775    fn personal_setup_requires_explicit_send_choice_and_reuses_compatible_profile() {
776        let profiles = BTreeMap::from([(
777            "personal".into(),
778            profile(
779                DesktopClientKind::BrowserCore,
780                DesktopAccount::Personal,
781                19300,
782            ),
783        )]);
784        let plan = run(
785            args(&[]),
786            &capabilities(),
787            None,
788            &profiles,
789            &mut "2\n2\n1\nyes\n".as_bytes(),
790            &mut vec![],
791            |_| panic!("must reuse existing browser profile"),
792        )
793        .unwrap()
794        .unwrap();
795        assert_eq!(plan.name, "personal");
796        assert_eq!(plan.args.desktop_port, Some(19300));
797        assert!(plan.args.allow_send);
798        let plan = run(
799            args(&["--read-only"]),
800            &capabilities(),
801            None,
802            &profiles,
803            &mut "2\n2\ny\n".as_bytes(),
804            &mut vec![],
805            |_| Ok(true),
806        )
807        .unwrap()
808        .unwrap();
809        assert!(plan.args.read_only && !plan.args.allow_send);
810    }
811
812    #[test]
813    fn cancelled_or_incomplete_setup_returns_no_plan() {
814        for input in ["", "q\n", "1\nq\n", "1\n2\nn\n", "1\n2\n"] {
815            let result = run(
816                args(&[]),
817                &capabilities(),
818                None,
819                &BTreeMap::new(),
820                &mut input.as_bytes(),
821                &mut vec![],
822                |_| Ok(true),
823            )
824            .unwrap();
825            if cfg!(any(target_os = "macos", target_os = "linux"))
826                || matches!(input, "" | "q\n" | "1\nq\n")
827            {
828                assert!(result.is_none(), "{input:?}");
829            }
830        }
831    }
832
833    #[test]
834    fn unrelated_profiles_are_preserved_and_explicit_replacement_needs_confirmation() {
835        let profiles = BTreeMap::from([(
836            "work".into(),
837            profile(DesktopClientKind::Native, DesktopAccount::WorkSchool, 9222),
838        )]);
839        let plan = run(
840            args(&[]),
841            &capabilities(),
842            None,
843            &profiles,
844            &mut "1\n2\ny\n".as_bytes(),
845            &mut vec![],
846            |_| Ok(true),
847        )
848        .unwrap()
849        .unwrap();
850        assert_eq!(plan.name, "work-2");
851        assert!(
852            run(
853                args(&[]),
854                &capabilities(),
855                Some("work"),
856                &profiles,
857                &mut "1\nn\n".as_bytes(),
858                &mut vec![],
859                |_| Ok(true)
860            )
861            .unwrap()
862            .is_none()
863        );
864    }
865
866    #[test]
867    fn graph_is_an_explicit_advanced_choice_and_does_not_reserve_a_browser_port() {
868        let plan = run(
869            args(&[]),
870            &capabilities(),
871            None,
872            &BTreeMap::new(),
873            &mut "3\n1\n2\n".as_bytes(),
874            &mut vec![],
875            |_| panic!("Graph has no browser port"),
876        )
877        .unwrap()
878        .unwrap();
879        assert_eq!(plan.args.backend, Some(BackendKind::Graph));
880        assert!(!plan.args.auto_start && !plan.browser_confirmed);
881    }
882
883    #[test]
884    fn windows_options_cannot_select_a_browser_even_by_typing_its_hidden_number() {
885        let mut caps = capabilities();
886        caps.browser = Availability::Blocked("Run inside WSL2 for browser mode.".into());
887        caps.core = caps.browser.clone();
888        let mut output = vec![];
889        let plan = run(
890            args(&[]),
891            &caps,
892            None,
893            &BTreeMap::new(),
894            &mut "3\n3\n4\n2\n2\n2\n".as_bytes(),
895            &mut output,
896            |_| panic!("native setup does not allocate browser ports"),
897        )
898        .unwrap()
899        .unwrap();
900        assert_eq!(plan.args.desktop_client, Some(DesktopClientKind::Native));
901        assert_eq!(plan.args.desktop_account, Some(DesktopAccount::Personal));
902        let text = String::from_utf8(output).unwrap();
903        assert!(!text.contains("  3  Full browser"));
904        assert!(!text.contains("  4  Lightweight browser"));
905        assert!(text.contains("Full browser — personal account is unavailable"));
906        assert!(text.contains("Run inside WSL2"));
907    }
908
909    #[test]
910    fn headless_linux_excludes_personal_and_native_and_offers_graph_device_code() {
911        let caps = Capabilities {
912            graph: Availability::Ready { download: false },
913            pending: Vec::new(),
914            browser: Availability::Blocked("Enable a graphical display.".into()),
915            core: Availability::Blocked("Enable a graphical display.".into()),
916            native: Availability::Blocked("Teams Desktop needs Windows interop.".into()),
917            graph_device_code: true,
918        };
919        let mut output = vec![];
920        let plan = run(
921            args(&[]),
922            &caps,
923            None,
924            &BTreeMap::new(),
925            &mut "2\n1\n2\n".as_bytes(),
926            &mut output,
927            |_| panic!("no browser"),
928        )
929        .unwrap()
930        .unwrap();
931        assert_eq!(plan.args.backend, Some(BackendKind::Graph));
932        assert!(plan.args.device_code);
933        assert!(plan.args.read_only && !plan.args.allow_send);
934        let text = String::from_utf8(output).unwrap();
935        assert!(!text.contains("  2  Personal"));
936        assert!(text.contains("Personal is unavailable"));
937        assert!(text.contains("no local browser is needed"));
938        assert!(
939            run(
940                args(&["--allow-send"]),
941                &caps,
942                None,
943                &BTreeMap::new(),
944                &mut "3\n".as_bytes(),
945                &mut vec![],
946                |_| panic!("no browser")
947            )
948            .is_err()
949        );
950    }
951
952    #[test]
953    fn personal_falls_back_to_full_browser_when_core_has_no_compatible_download() {
954        let mut caps = capabilities();
955        caps.core =
956            Availability::Blocked("No matching browser pair exists for this platform.".into());
957        let plan = run(
958            args(&[]),
959            &caps,
960            None,
961            &BTreeMap::new(),
962            &mut "2\n2\ny\n".as_bytes(),
963            &mut vec![],
964            |_| Ok(true),
965        )
966        .unwrap()
967        .unwrap();
968        assert_eq!(plan.args.desktop_client, Some(DesktopClientKind::Browser));
969        assert_eq!(plan.args.desktop_account, Some(DesktopAccount::Personal));
970        assert!(plan.args.auto_start);
971    }
972
973    #[test]
974    fn graph_cannot_be_selected_with_an_explicit_desktop_send_flag() {
975        let mut output = vec![];
976        assert!(
977            run(
978                args(&["--allow-send"]),
979                &capabilities(),
980                None,
981                &BTreeMap::new(),
982                &mut "3\n1\nq\n".as_bytes(),
983                &mut output,
984                |_| Ok(true)
985            )
986            .unwrap()
987            .is_none()
988        );
989        let text = String::from_utf8(output).unwrap();
990        assert!(!text.contains("  1  Microsoft Graph"));
991        assert!(text.contains("--allow-send applies to"));
992    }
993
994    #[test]
995    fn conflicting_graph_environment_cannot_be_selected_but_desktop_still_works() {
996        let mut caps = capabilities();
997        caps.graph =
998            Availability::Blocked("Unset TEAMS_ACCESS_TOKEN before guided Graph setup.".into());
999        let mut output = vec![];
1000        let plan = run(
1001            args(&[]),
1002            &caps,
1003            None,
1004            &BTreeMap::new(),
1005            &mut "3\n1\n2\n1\n2\n".as_bytes(),
1006            &mut output,
1007            |_| panic!("native has no browser port"),
1008        )
1009        .unwrap()
1010        .unwrap();
1011        assert_eq!(plan.args.backend, Some(BackendKind::Desktop));
1012        assert_eq!(plan.args.desktop_client, Some(DesktopClientKind::Native));
1013        let text = String::from_utf8(output).unwrap();
1014        assert!(!text.contains("  1  Microsoft Graph"));
1015        assert!(text.contains(
1016            "Microsoft Graph — work/school, including teams and channels is unavailable"
1017        ));
1018        assert!(text.contains("Unset TEAMS_ACCESS_TOKEN"));
1019    }
1020
1021    #[test]
1022    fn lightweight_download_is_disclosed_before_consent() {
1023        let mut caps = capabilities();
1024        caps.core = Availability::Ready { download: true };
1025        let mut output = vec![];
1026        assert!(
1027            run(
1028                args(&[]),
1029                &caps,
1030                None,
1031                &BTreeMap::new(),
1032                &mut "2\n2\nn\n".as_bytes(),
1033                &mut output,
1034                |_| Ok(true)
1035            )
1036            .unwrap()
1037            .is_none()
1038        );
1039        let text = String::from_utf8(output).unwrap();
1040        assert!(text.contains("download from Google after you confirm"));
1041    }
1042
1043    #[test]
1044    fn existing_connections_require_a_choice_and_graph_updates_preserve_registration() {
1045        let mut graph = profile(DesktopClientKind::Native, DesktopAccount::Auto, 9222);
1046        graph.backend = BackendKind::Graph;
1047        graph.client_id = "custom-application".into();
1048        graph.tenant = "custom-tenant".into();
1049        let profiles = BTreeMap::from([("graph".into(), graph)]);
1050        let fresh = run(
1051            args(&[]),
1052            &capabilities(),
1053            None,
1054            &profiles,
1055            &mut "3\n1\n1\n2\n".as_bytes(),
1056            &mut vec![],
1057            |_| panic!(),
1058        )
1059        .unwrap()
1060        .unwrap();
1061        assert_eq!(fresh.name, "graph-2");
1062        let update = run(
1063            args(&[]),
1064            &capabilities(),
1065            Some("graph"),
1066            &profiles,
1067            &mut "3\n1\ny\n2\n".as_bytes(),
1068            &mut vec![],
1069            |_| panic!(),
1070        )
1071        .unwrap()
1072        .unwrap();
1073        assert_eq!(update.args.client_id.as_deref(), Some("custom-application"));
1074        assert_eq!(update.args.tenant.as_deref(), Some("custom-tenant"));
1075        assert!(
1076            run(
1077                args(&[]),
1078                &capabilities(),
1079                Some("graph"),
1080                &profiles,
1081                &mut "3\n1\nn\n".as_bytes(),
1082                &mut vec![],
1083                |_| panic!()
1084            )
1085            .unwrap()
1086            .is_none()
1087        );
1088    }
1089
1090    #[test]
1091    fn unfinished_browser_setup_can_resume_its_exact_port_or_keep_a_separate_account() {
1092        let mut caps = capabilities();
1093        caps.pending.push(crate::config::PendingBrowserSetup {
1094            name: "work".into(),
1095            port: 19311,
1096            account: DesktopAccount::WorkSchool,
1097            client: DesktopClientKind::Browser,
1098        });
1099        let resumed = run(
1100            args(&[]),
1101            &caps,
1102            None,
1103            &BTreeMap::new(),
1104            &mut "1\n1\n2\ny\n".as_bytes(),
1105            &mut vec![],
1106            |_| panic!("reuse the exact saved port"),
1107        )
1108        .unwrap()
1109        .unwrap();
1110        assert_eq!(resumed.args.desktop_port, Some(19311));
1111        let separate = run(
1112            args(&[]),
1113            &caps,
1114            None,
1115            &BTreeMap::new(),
1116            &mut "1\n2\n2\ny\n".as_bytes(),
1117            &mut vec![],
1118            |_| Ok(true),
1119        )
1120        .unwrap()
1121        .unwrap();
1122        assert_eq!(separate.name, "work-2");
1123        assert_ne!(separate.args.desktop_port, Some(19311));
1124        assert!(separate.forget_pending.is_none());
1125        let forgotten = run(
1126            args(&[]),
1127            &caps,
1128            None,
1129            &BTreeMap::new(),
1130            &mut "1\n3\ny\n2\ny\n".as_bytes(),
1131            &mut vec![],
1132            |_| Ok(true),
1133        )
1134        .unwrap()
1135        .unwrap();
1136        assert_eq!(forgotten.name, "work");
1137        assert_eq!(forgotten.forget_pending.as_ref(), caps.pending.first());
1138        assert_ne!(forgotten.args.desktop_port, Some(19311));
1139        assert!(
1140            run(
1141                args(&[]),
1142                &caps,
1143                None,
1144                &BTreeMap::new(),
1145                &mut "1\n3\ny\n2\nn\n".as_bytes(),
1146                &mut vec![],
1147                |_| Ok(true),
1148            )
1149            .unwrap()
1150            .is_none()
1151        );
1152    }
1153
1154    #[test]
1155    fn existing_nondefault_names_are_reachable_and_new_names_skip_pending_accounts() {
1156        let existing = profile(
1157            DesktopClientKind::Browser,
1158            DesktopAccount::WorkSchool,
1159            19312,
1160        );
1161        let profiles = BTreeMap::from([("work-2".into(), existing)]);
1162        let mut caps = capabilities();
1163        caps.pending.push(crate::config::PendingBrowserSetup {
1164            name: "work".into(),
1165            port: 19311,
1166            account: DesktopAccount::WorkSchool,
1167            client: DesktopClientKind::Browser,
1168        });
1169        let update = run(
1170            args(&[]),
1171            &caps,
1172            None,
1173            &profiles,
1174            &mut "1\n2\n2\ny\n".as_bytes(),
1175            &mut vec![],
1176            |_| panic!(),
1177        )
1178        .unwrap()
1179        .unwrap();
1180        assert_eq!(update.name, "work-2");
1181        assert_eq!(update.args.desktop_port, Some(19312));
1182        let new = run(
1183            args(&[]),
1184            &caps,
1185            None,
1186            &profiles,
1187            &mut "1\n1\n2\n2\ny\n".as_bytes(),
1188            &mut vec![],
1189            |_| Ok(true),
1190        )
1191        .unwrap()
1192        .unwrap();
1193        assert_eq!(new.name, "work-3");
1194        assert!(![19311, 19312].contains(&new.args.desktop_port.unwrap()));
1195    }
1196
1197    #[test]
1198    fn connecting_another_account_offers_all_matching_unfinished_sign_ins() {
1199        let profiles = BTreeMap::from([(
1200            "work".into(),
1201            profile(
1202                DesktopClientKind::Browser,
1203                DesktopAccount::WorkSchool,
1204                19310,
1205            ),
1206        )]);
1207        let mut caps = capabilities();
1208        for (name, port) in [("work-2", 19311), ("work-3", 19312)] {
1209            caps.pending.push(crate::config::PendingBrowserSetup {
1210                name: name.into(),
1211                port,
1212                account: DesktopAccount::WorkSchool,
1213                client: DesktopClientKind::Browser,
1214            });
1215        }
1216        let mut output = vec![];
1217        let resumed = run(
1218            args(&[]),
1219            &caps,
1220            None,
1221            &profiles,
1222            &mut "1\n1\n2\n1\n2\ny\n".as_bytes(),
1223            &mut output,
1224            |_| panic!("resume the saved port"),
1225        )
1226        .unwrap()
1227        .unwrap();
1228        assert_eq!(resumed.name, "work-3");
1229        assert_eq!(resumed.args.desktop_port, Some(19312));
1230        let text = String::from_utf8(output).unwrap();
1231        assert!(text.contains("Continue unfinished 'work-2' (port 19311)"));
1232        assert!(text.contains("Continue unfinished 'work-3' (port 19312)"));
1233        let new = run(
1234            args(&[]),
1235            &caps,
1236            None,
1237            &profiles,
1238            &mut "1\n1\n3\n2\ny\n".as_bytes(),
1239            &mut vec![],
1240            |_| Ok(true),
1241        )
1242        .unwrap()
1243        .unwrap();
1244        assert_eq!(new.name, "work-4");
1245        assert!(![19310, 19311, 19312].contains(&new.args.desktop_port.unwrap()));
1246    }
1247
1248    #[test]
1249    fn unavailable_send_interface_needs_explicit_consent_to_continue_read_only() {
1250        for (input, expected) in [
1251            ("y\n", true),
1252            ("yes\n", true),
1253            ("\n", false),
1254            ("n\n", false),
1255            ("q\n", false),
1256            ("", false),
1257        ] {
1258            let mut output = vec![];
1259            assert_eq!(
1260                read_only_fallback(
1261                    "No send client in this account.",
1262                    &mut input.as_bytes(),
1263                    &mut output
1264                )
1265                .unwrap(),
1266                expected
1267            );
1268            assert!(
1269                String::from_utf8(output)
1270                    .unwrap()
1271                    .contains("Continue with read-only access [n]")
1272            );
1273        }
1274    }
1275}