1use 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
73pub 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
125fn 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}