1use crate::api::{execute_api_call, ApiCallArgs, ApiCallContext, ApiCallResponse, ApiClient};
7use crate::auth;
8use crate::debug;
9use crate::oauth;
10use crate::profile::{
11 create_token_store, default_config_path, make_token_key, resolve_profile_full, TokenType,
12};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct LoginArgs {
17 pub profile_name: Option<String>,
18 pub client_id: Option<String>,
19 pub bot_scopes: Option<Vec<String>>,
20 pub user_scopes: Option<Vec<String>>,
21 pub tunnel_mode: TunnelMode,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub enum TunnelMode {
27 None,
28 Cloudflared(Option<String>),
29 Ngrok(Option<String>),
30}
31
32impl TunnelMode {
33 pub fn is_enabled(&self) -> bool {
35 !matches!(self, TunnelMode::None)
36 }
37
38 pub fn is_cloudflared(&self) -> bool {
40 matches!(self, TunnelMode::Cloudflared(_))
41 }
42
43 #[allow(dead_code)]
45 pub fn is_ngrok(&self) -> bool {
46 matches!(self, TunnelMode::Ngrok(_))
47 }
48}
49
50pub fn parse_login_args(args: &[String]) -> Result<LoginArgs, String> {
67 let mut profile_name: Option<String> = None;
68 let mut client_id: Option<String> = None;
69 let mut cloudflared_path: Option<String> = None;
70 let mut ngrok_path: Option<String> = None;
71 let mut bot_scopes: Option<Vec<String>> = None;
72 let mut user_scopes: Option<Vec<String>> = None;
73
74 let mut i = 0;
75 while i < args.len() {
76 if args[i].starts_with("--") {
77 match args[i].as_str() {
78 "--client-id" => {
79 i += 1;
80 if i < args.len() {
81 client_id = Some(args[i].clone());
82 } else {
83 return Err("--client-id requires a value".to_string());
84 }
85 }
86 "--cloudflared" => {
87 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
89 i += 1;
90 cloudflared_path = Some(args[i].clone());
91 } else {
92 cloudflared_path = Some("cloudflared".to_string());
94 }
95 }
96 "--ngrok" => {
97 if i + 1 < args.len() && !args[i + 1].starts_with("--") {
99 i += 1;
100 ngrok_path = Some(args[i].clone());
101 } else {
102 ngrok_path = Some("ngrok".to_string());
104 }
105 }
106 "--bot-scopes" => {
107 i += 1;
108 if i < args.len() {
109 let scopes_input: Vec<String> =
110 args[i].split(',').map(|s| s.trim().to_string()).collect();
111 bot_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, true));
113 } else {
114 return Err("--bot-scopes requires a value".to_string());
115 }
116 }
117 "--user-scopes" => {
118 i += 1;
119 if i < args.len() {
120 let scopes_input: Vec<String> =
121 args[i].split(',').map(|s| s.trim().to_string()).collect();
122 user_scopes = Some(oauth::expand_scopes_with_context(&scopes_input, false));
124 } else {
125 return Err("--user-scopes requires a value".to_string());
126 }
127 }
128 _ => {
129 return Err(format!("Unknown option: {}", args[i]));
130 }
131 }
132 } else if profile_name.is_none() {
133 profile_name = Some(args[i].clone());
134 } else {
135 return Err(format!("Unexpected argument: {}", args[i]));
136 }
137 i += 1;
138 }
139
140 if cloudflared_path.is_some() && ngrok_path.is_some() {
142 return Err("Cannot specify both --cloudflared and --ngrok at the same time".to_string());
143 }
144
145 let tunnel_mode = if let Some(path) = cloudflared_path {
147 TunnelMode::Cloudflared(Some(path))
148 } else if let Some(path) = ngrok_path {
149 TunnelMode::Ngrok(Some(path))
150 } else {
151 TunnelMode::None
152 };
153
154 Ok(LoginArgs {
155 profile_name,
156 client_id,
157 bot_scopes,
158 user_scopes,
159 tunnel_mode,
160 })
161}
162
163pub async fn run_auth_login(args: &[String], non_interactive: bool) -> Result<(), String> {
165 let parsed_args = parse_login_args(args)?;
167
168 let redirect_uri = "http://127.0.0.1:8765/callback".to_string();
170
171 let base_url = std::env::var("SLACK_OAUTH_BASE_URL").ok();
173
174 if parsed_args.tunnel_mode.is_enabled() {
176 if non_interactive {
178 let mut missing = Vec::new();
179 if parsed_args.client_id.is_none() {
180 missing.push("--client-id");
181 }
182 if parsed_args.bot_scopes.is_none() {
183 missing.push("--bot-scopes");
184 }
185 if parsed_args.user_scopes.is_none() {
186 missing.push("--user-scopes");
187 }
188 if !missing.is_empty() {
189 return Err(format!(
190 "Missing required parameters in non-interactive mode: {}\n\
191 Provide them via CLI flags:\n\
192 Example: slack-rs auth login --cloudflared --client-id <id> --bot-scopes <scopes> --user-scopes <scopes>",
193 missing.join(", ")
194 ));
195 }
196 }
197
198 let client_id = if let Some(id) = parsed_args.client_id {
200 id
201 } else if non_interactive {
202 return Err(
203 "Client ID is required in non-interactive mode. Use --client-id flag.".to_string(),
204 );
205 } else {
206 use std::io::{self, Write};
207 print!("Enter Slack Client ID: ");
208 io::stdout().flush().unwrap();
209 let mut input = String::new();
210 io::stdin().read_line(&mut input).unwrap();
211 input.trim().to_string()
212 };
213
214 let bot_scopes = parsed_args.bot_scopes.unwrap_or_else(oauth::bot_all_scopes);
216 let user_scopes = parsed_args
217 .user_scopes
218 .unwrap_or_else(oauth::user_all_scopes);
219
220 if debug::enabled() {
221 debug::log("Preparing to call login_with_credentials_extended");
222 debug::log(format!("bot_scopes_count={}", bot_scopes.len()));
223 debug::log(format!("user_scopes_count={}", user_scopes.len()));
224 }
225
226 let client_secret = if non_interactive {
228 return Err("Client secret cannot be provided in non-interactive mode with --cloudflared/--ngrok. Use the standard login flow (without --cloudflared/--ngrok) to save credentials first.".to_string());
229 } else {
230 auth::prompt_for_client_secret()
231 .map_err(|e| format!("Failed to read client secret: {}", e))?
232 };
233
234 auth::login_with_credentials_extended(
236 client_id,
237 client_secret,
238 bot_scopes,
239 user_scopes,
240 parsed_args.profile_name,
241 parsed_args.tunnel_mode.is_cloudflared(),
242 )
243 .await
244 .map_err(|e| e.to_string())
245 } else {
246 auth::login_with_credentials(
249 parsed_args.client_id,
250 parsed_args.profile_name,
251 redirect_uri,
252 vec![], parsed_args.bot_scopes,
254 parsed_args.user_scopes,
255 base_url,
256 non_interactive,
257 )
258 .await
259 .map_err(|e| e.to_string())
260 }
261}
262
263fn should_show_private_channel_guidance(
265 api_args: &ApiCallArgs,
266 token_type: &str,
267 response: &ApiCallResponse,
268) -> bool {
269 if api_args.method != "conversations.list" || token_type != "bot" {
271 return false;
272 }
273
274 if let Some(types) = api_args.params.get("types") {
276 if !types.contains("private_channel") {
277 return false;
278 }
279 } else {
280 return false;
281 }
282
283 if let Some(channels) = response.response.get("channels") {
285 if let Some(channels_array) = channels.as_array() {
286 return channels_array.is_empty();
287 }
288 }
289
290 false
291}
292
293fn infer_default_token_type(
296 token_store: &dyn crate::profile::TokenStore,
297 team_id: &str,
298 user_id: &str,
299) -> TokenType {
300 let user_token_key = format!("{}:{}:user", team_id, user_id);
301 if token_store.exists(&user_token_key) {
302 TokenType::User
303 } else {
304 TokenType::Bot
305 }
306}
307
308#[derive(Debug)]
310struct ResolvedToken {
311 token: String,
312 token_type: TokenType,
313}
314
315fn resolve_token(
336 token_store: &dyn crate::profile::TokenStore,
337 team_id: &str,
338 user_id: &str,
339 cli_token_type: Option<TokenType>,
340 profile_default_token_type: Option<TokenType>,
341 profile_name: &str,
342) -> Result<ResolvedToken, String> {
343 let inferred_default = infer_default_token_type(token_store, team_id, user_id);
345
346 let resolved_token_type =
348 TokenType::resolve(cli_token_type, profile_default_token_type, inferred_default);
349
350 let token_key_bot = make_token_key(team_id, user_id);
352 let token_key_user = format!("{}:{}:user", team_id, user_id);
353
354 let token_key = match resolved_token_type {
356 TokenType::Bot => token_key_bot.clone(),
357 TokenType::User => token_key_user.clone(),
358 };
359
360 let explicit_request = cli_token_type.is_some() || profile_default_token_type.is_some();
363
364 let token = if let Ok(env_token) = std::env::var("SLACK_TOKEN") {
366 env_token
367 } else {
368 match token_store.get(&token_key) {
370 Ok(t) => t,
371 Err(_) => {
372 if explicit_request {
374 return Err(format!(
376 "No {} token found for profile '{}' ({}:{}). Explicitly requested token type not available. Set SLACK_TOKEN environment variable or run 'slack login' to obtain a {} token.",
377 resolved_token_type, profile_name, team_id, user_id, resolved_token_type
378 ));
379 } else {
380 if resolved_token_type == TokenType::User {
382 if let Ok(bot_token) = token_store.get(&token_key_bot) {
383 eprintln!(
384 "Warning: User token not found, falling back to bot token for profile '{}'",
385 profile_name
386 );
387 return Ok(ResolvedToken {
388 token: bot_token,
389 token_type: TokenType::Bot,
390 });
391 } else {
392 return Err(format!(
393 "No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
394 resolved_token_type, profile_name, team_id, user_id
395 ));
396 }
397 } else {
398 return Err(format!(
399 "No {} token found for profile '{}' ({}:{}). Set SLACK_TOKEN environment variable or run 'slack login' to obtain a token.",
400 resolved_token_type, profile_name, team_id, user_id
401 ));
402 }
403 }
404 }
405 }
406 };
407
408 Ok(ResolvedToken {
409 token,
410 token_type: resolved_token_type,
411 })
412}
413
414pub async fn run_api_call(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
416 let api_args = ApiCallArgs::parse(&args)?;
418
419 let profile_name = crate::cli::resolve_profile_name(&args);
421
422 let config_path = default_config_path()?;
424
425 let profile = resolve_profile_full(&config_path, &profile_name)
427 .map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
428
429 let context = ApiCallContext {
431 profile_name: Some(profile_name.clone()),
432 team_id: profile.team_id.clone(),
433 user_id: profile.user_id.clone(),
434 };
435
436 let token_store =
438 create_token_store().map_err(|e| format!("Failed to create token store: {}", e))?;
439
440 let resolved = resolve_token(
442 &*token_store,
443 &profile.team_id,
444 &profile.user_id,
445 api_args.token_type,
446 profile.default_token_type,
447 &profile_name,
448 )
449 .map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
450
451 let token = resolved.token;
452 let resolved_token_type = resolved.token_type;
453
454 let debug_level = debug::get_debug_level(&args);
456
457 let token_store_backend = if std::env::var("SLACK_TOKEN").is_ok() {
459 "environment"
460 } else {
461 "file"
462 };
463
464 let endpoint = format!("https://slack.com/api/{}", api_args.method);
465
466 debug::log_api_context(
467 debug_level,
468 Some(&profile_name),
469 token_store_backend,
470 resolved_token_type.as_str(),
471 &api_args.method,
472 &endpoint,
473 );
474
475 let client = ApiClient::new();
477
478 let response = execute_api_call(
480 &client,
481 &api_args,
482 &token,
483 &context,
484 resolved_token_type.as_str(),
485 "api call",
486 )
487 .await?;
488
489 debug::log_error_code(debug_level, &response.response);
491
492 crate::api::display_error_guidance(&response);
494
495 if should_show_private_channel_guidance(&api_args, resolved_token_type.as_str(), &response) {
497 eprintln!();
498 eprintln!("Note: The conversation list for private channels is empty.");
499 eprintln!("Bot tokens can only see private channels where the bot is a member.");
500 eprintln!("To list all private channels, use a User Token with appropriate scopes.");
501 eprintln!("Run: slackcli auth login (with user_scopes) or use --token-type user");
502 eprintln!();
503 }
504
505 let json = if api_args.raw {
509 serde_json::to_string_pretty(&response.response)?
510 } else {
511 serde_json::to_string_pretty(&response)?
512 };
513 println!("{}", json);
514
515 Ok(())
516}
517
518struct ExportImportArgs {
520 passphrase_env: Option<String>,
521 yes: bool,
522 lang: Option<String>,
523}
524
525impl ExportImportArgs {
526 fn parse(args: &[String]) -> (Self, Vec<(usize, String)>) {
529 let mut passphrase_env: Option<String> = None;
530 let mut yes = false;
531 let mut lang: Option<String> = None;
532 let mut remaining = Vec::new();
533
534 let mut i = 0;
535 while i < args.len() {
536 match args[i].as_str() {
537 "--passphrase-env" => {
538 i += 1;
539 if i < args.len() {
540 passphrase_env = Some(args[i].clone());
541 }
542 }
543 "--passphrase-prompt" => {
544 }
546 "--yes" => {
547 yes = true;
548 }
549 "--lang" => {
550 i += 1;
551 if i < args.len() {
552 lang = Some(args[i].clone());
553 }
554 }
555 _ => {
556 remaining.push((i, args[i].clone()));
558 }
559 }
560 i += 1;
561 }
562
563 (
564 Self {
565 passphrase_env,
566 yes,
567 lang,
568 },
569 remaining,
570 )
571 }
572
573 fn get_messages(&self) -> auth::Messages {
575 if let Some(ref lang_code) = self.lang {
576 if let Some(language) = auth::Language::from_code(lang_code) {
577 auth::Messages::new(language)
578 } else {
579 auth::Messages::default()
580 }
581 } else {
582 auth::Messages::default()
583 }
584 }
585
586 fn get_passphrase(&self, messages: &auth::Messages) -> Result<String, String> {
588 if let Some(ref env_var) = self.passphrase_env {
589 match std::env::var(env_var) {
590 Ok(val) => Ok(val),
591 Err(_) => {
592 eprintln!(
594 "Warning: Environment variable {} not found, prompting for passphrase",
595 env_var
596 );
597 rpassword::prompt_password(messages.get("prompt.passphrase"))
598 .map_err(|e| format!("Error reading passphrase: {}", e))
599 }
600 }
601 } else {
602 rpassword::prompt_password(messages.get("prompt.passphrase"))
604 .map_err(|e| format!("Error reading passphrase: {}", e))
605 }
606 }
607}
608
609pub async fn handle_export_command(args: &[String]) {
611 if args.iter().any(|arg| arg == "-h" || arg == "--help") {
613 super::help::print_export_help();
614 return;
615 }
616
617 let (common_args, remaining) = ExportImportArgs::parse(args);
619
620 let mut profile_name: Option<String> = None;
622 let mut all = false;
623 let mut output_path: Option<String> = None;
624
625 for (idx, arg) in remaining {
626 match arg.as_str() {
627 "--profile" => {
628 if idx + 1 < args.len() {
630 profile_name = Some(args[idx + 1].clone());
631 }
632 }
633 "--all" => {
634 all = true;
635 }
636 "--out" => {
637 if idx + 1 < args.len() {
639 output_path = Some(args[idx + 1].clone());
640 }
641 }
642 _ => {
643 if idx > 0 {
645 let prev = &args[idx - 1];
646 if prev == "--profile"
647 || prev == "--out"
648 || prev == "--passphrase-env"
649 || prev == "--lang"
650 {
651 continue;
653 }
654 }
655 eprintln!("Unknown option: {}", arg);
656 std::process::exit(1);
657 }
658 }
659 }
660
661 let messages = common_args.get_messages();
663
664 if !common_args.yes {
666 eprintln!("{}", messages.get("warn.export_sensitive"));
667 eprintln!("Error: --yes flag is required to confirm this dangerous operation");
668 std::process::exit(1);
669 }
670
671 let output = match output_path {
673 Some(path) => path,
674 None => {
675 eprintln!("Error: --out <file> is required");
676 std::process::exit(1);
677 }
678 };
679
680 let passphrase = match common_args.get_passphrase(&messages) {
682 Ok(pass) => pass,
683 Err(e) => {
684 eprintln!("{}", e);
685 std::process::exit(1);
686 }
687 };
688
689 let options = auth::ExportOptions {
690 profile_name,
691 all,
692 output_path: output,
693 passphrase,
694 yes: common_args.yes,
695 };
696
697 let token_store = create_token_store().expect("Failed to create token store");
698 match auth::export_profiles(&*token_store, &options) {
699 Ok(result) => {
700 if !result.skipped_profiles.is_empty() {
702 eprintln!("{}", messages.get("warn.export_skipped"));
703 for profile_name in &result.skipped_profiles {
704 eprintln!(" - {}", profile_name);
705 }
706 eprintln!();
707 eprintln!(
708 "{}",
709 messages
710 .get("info.export_summary")
711 .replace("{exported}", &result.exported_count.to_string())
712 .replace("{skipped}", &result.skipped_profiles.len().to_string())
713 );
714 eprintln!();
715 }
716 println!("{}", messages.get("success.export"));
717 }
718 Err(e) => {
719 eprintln!("Export failed: {}", e);
720 std::process::exit(1);
721 }
722 }
723}
724
725pub async fn handle_import_command(args: &[String]) {
727 if args.iter().any(|arg| arg == "-h" || arg == "--help") {
729 super::help::print_import_help();
730 return;
731 }
732
733 let (common_args, remaining) = ExportImportArgs::parse(args);
735
736 let mut input_path: Option<String> = None;
738 let mut force = false;
739 let mut dry_run = false;
740 let mut json = false;
741
742 for (idx, arg) in remaining {
743 match arg.as_str() {
744 "--in" => {
745 if idx + 1 < args.len() {
747 input_path = Some(args[idx + 1].clone());
748 }
749 }
750 "--force" => {
751 force = true;
752 }
753 "--dry-run" => {
754 dry_run = true;
755 }
756 "--json" => {
757 json = true;
758 }
759 _ => {
760 if idx > 0 {
762 let prev = &args[idx - 1];
763 if prev == "--in" || prev == "--passphrase-env" || prev == "--lang" {
764 continue;
766 }
767 }
768 eprintln!("Unknown option: {}", arg);
769 std::process::exit(1);
770 }
771 }
772 }
773
774 let messages = common_args.get_messages();
776
777 let input = match input_path {
779 Some(path) => path,
780 None => {
781 eprintln!("Error: --in <file> is required");
782 std::process::exit(1);
783 }
784 };
785
786 let passphrase = match common_args.get_passphrase(&messages) {
788 Ok(pass) => pass,
789 Err(e) => {
790 eprintln!("{}", e);
791 std::process::exit(1);
792 }
793 };
794
795 let options = auth::ImportOptions {
796 input_path: input,
797 passphrase,
798 yes: common_args.yes,
799 force,
800 dry_run,
801 json,
802 };
803
804 let token_store = create_token_store().expect("Failed to create token store");
805 match auth::import_profiles(&*token_store, &options) {
806 Ok(result) => {
807 if json {
808 match serde_json::to_string_pretty(&result) {
810 Ok(json_output) => {
811 println!("{}", json_output);
812 }
813 Err(e) => {
814 eprintln!("Failed to serialize result to JSON: {}", e);
815 std::process::exit(1);
816 }
817 }
818 } else {
819 if result.dry_run {
821 println!("Dry-run mode: no changes were written.");
822 println!();
823 }
824
825 println!("Import Summary:");
826 println!(" Total: {}", result.summary.total);
827 println!(" Updated: {}", result.summary.updated);
828 println!(" Skipped: {}", result.summary.skipped);
829 println!(" Overwritten: {}", result.summary.overwritten);
830 println!();
831 println!("Profile Details:");
832 for profile_result in &result.profiles {
833 println!(
834 " {} - {} ({})",
835 profile_result.profile_name, profile_result.action, profile_result.reason
836 );
837 }
838 println!();
839
840 if result.dry_run {
841 println!("Dry-run complete. Re-run without --dry-run to apply changes.");
842 } else {
843 println!("{}", messages.get("success.import"));
844 }
845 }
846 }
847 Err(e) => {
848 eprintln!("Import failed: {}", e);
849 std::process::exit(1);
850 }
851 }
852}
853
854pub fn run_install_skill(args: &[String]) -> Result<(), String> {
863 use crate::skills;
864 use serde_json::json;
865
866 let global = args.iter().any(|arg| arg == "--global");
867
868 let source = args
870 .iter()
871 .find(|arg| !arg.starts_with("--"))
872 .map(|s| s.as_str());
873
874 let installed = skills::install_skill(source, global).map_err(|e| e.to_string())?;
876
877 let response = json!({
879 "schemaVersion": "1.0",
880 "type": "skill-installation",
881 "ok": true,
882 "skills": [
883 {
884 "name": installed.name,
885 "path": installed.path,
886 "source_type": installed.source_type,
887 }
888 ]
889 });
890
891 println!("{}", serde_json::to_string_pretty(&response).unwrap());
893
894 Ok(())
895}
896
897#[cfg(test)]
898mod tests {
899 use super::*;
900 use crate::api::call::ApiCallMeta;
901 use crate::profile::{InMemoryTokenStore, TokenStore};
902 use serde_json::json;
903 use serial_test::serial;
904 use std::collections::HashMap;
905
906 #[test]
907 fn test_parse_login_args_empty() {
908 let args = vec![];
909 let result = parse_login_args(&args);
910 assert!(result.is_ok());
911 let parsed = result.unwrap();
912 assert_eq!(parsed.profile_name, None);
913 assert_eq!(parsed.client_id, None);
914 assert_eq!(parsed.bot_scopes, None);
915 assert_eq!(parsed.user_scopes, None);
916 assert_eq!(parsed.tunnel_mode, TunnelMode::None);
917 }
918
919 #[test]
920 fn test_parse_login_args_profile_only() {
921 let args = vec!["my-profile".to_string()];
922 let result = parse_login_args(&args);
923 assert!(result.is_ok());
924 let parsed = result.unwrap();
925 assert_eq!(parsed.profile_name, Some("my-profile".to_string()));
926 assert_eq!(parsed.tunnel_mode, TunnelMode::None);
927 }
928
929 #[test]
930 fn test_parse_login_args_with_client_id() {
931 let args = vec!["--client-id".to_string(), "123.456".to_string()];
932 let result = parse_login_args(&args);
933 assert!(result.is_ok());
934 let parsed = result.unwrap();
935 assert_eq!(parsed.client_id, Some("123.456".to_string()));
936 }
937
938 #[test]
939 fn test_parse_login_args_cloudflared_default() {
940 let args = vec!["--cloudflared".to_string()];
941 let result = parse_login_args(&args);
942 assert!(result.is_ok());
943 let parsed = result.unwrap();
944 assert!(matches!(
945 parsed.tunnel_mode,
946 TunnelMode::Cloudflared(Some(_))
947 ));
948 if let TunnelMode::Cloudflared(Some(path)) = parsed.tunnel_mode {
949 assert_eq!(path, "cloudflared");
950 }
951 }
952
953 #[test]
954 fn test_parse_login_args_cloudflared_with_path() {
955 let args = vec![
956 "--cloudflared".to_string(),
957 "/usr/bin/cloudflared".to_string(),
958 ];
959 let result = parse_login_args(&args);
960 assert!(result.is_ok());
961 let parsed = result.unwrap();
962 if let TunnelMode::Cloudflared(Some(path)) = parsed.tunnel_mode {
963 assert_eq!(path, "/usr/bin/cloudflared");
964 } else {
965 panic!("Expected Cloudflared tunnel mode");
966 }
967 }
968
969 #[test]
970 fn test_parse_login_args_ngrok_default() {
971 let args = vec!["--ngrok".to_string()];
972 let result = parse_login_args(&args);
973 assert!(result.is_ok());
974 let parsed = result.unwrap();
975 assert!(matches!(parsed.tunnel_mode, TunnelMode::Ngrok(Some(_))));
976 if let TunnelMode::Ngrok(Some(path)) = parsed.tunnel_mode {
977 assert_eq!(path, "ngrok");
978 }
979 }
980
981 #[test]
982 fn test_parse_login_args_cloudflared_ngrok_mutual_exclusion() {
983 let args = vec!["--cloudflared".to_string(), "--ngrok".to_string()];
984 let result = parse_login_args(&args);
985 assert!(result.is_err());
986 assert!(result
987 .unwrap_err()
988 .contains("Cannot specify both --cloudflared and --ngrok"));
989 }
990
991 #[test]
992 fn test_parse_login_args_bot_scopes() {
993 let args = vec![
994 "--bot-scopes".to_string(),
995 "chat:write,users:read".to_string(),
996 ];
997 let result = parse_login_args(&args);
998 assert!(result.is_ok());
999 let parsed = result.unwrap();
1000 assert!(parsed.bot_scopes.is_some());
1001 let scopes = parsed.bot_scopes.unwrap();
1002 assert!(scopes.contains(&"chat:write".to_string()));
1003 assert!(scopes.contains(&"users:read".to_string()));
1004 }
1005
1006 #[test]
1007 fn test_parse_login_args_user_scopes() {
1008 let args = vec![
1009 "--user-scopes".to_string(),
1010 "search:read,users:read".to_string(),
1011 ];
1012 let result = parse_login_args(&args);
1013 assert!(result.is_ok());
1014 let parsed = result.unwrap();
1015 assert!(parsed.user_scopes.is_some());
1016 }
1017
1018 #[test]
1019 fn test_parse_login_args_all_parameters() {
1020 let args = vec![
1021 "work".to_string(),
1022 "--client-id".to_string(),
1023 "123.456".to_string(),
1024 "--bot-scopes".to_string(),
1025 "chat:write".to_string(),
1026 "--user-scopes".to_string(),
1027 "users:read".to_string(),
1028 "--cloudflared".to_string(),
1029 ];
1030 let result = parse_login_args(&args);
1031 assert!(result.is_ok());
1032 let parsed = result.unwrap();
1033 assert_eq!(parsed.profile_name, Some("work".to_string()));
1034 assert_eq!(parsed.client_id, Some("123.456".to_string()));
1035 assert!(parsed.bot_scopes.is_some());
1036 assert!(parsed.user_scopes.is_some());
1037 assert!(parsed.tunnel_mode.is_cloudflared());
1038 }
1039
1040 #[test]
1041 fn test_parse_login_args_unknown_option() {
1042 let args = vec!["--unknown-flag".to_string()];
1043 let result = parse_login_args(&args);
1044 assert!(result.is_err());
1045 assert!(result.unwrap_err().contains("Unknown option"));
1046 }
1047
1048 #[test]
1049 fn test_parse_login_args_unexpected_positional() {
1050 let args = vec!["profile1".to_string(), "profile2".to_string()];
1051 let result = parse_login_args(&args);
1052 assert!(result.is_err());
1053 assert!(result.unwrap_err().contains("Unexpected argument"));
1054 }
1055
1056 #[test]
1057 fn test_parse_login_args_client_id_missing_value() {
1058 let args = vec!["--client-id".to_string()];
1059 let result = parse_login_args(&args);
1060 assert!(result.is_err());
1061 assert!(result.unwrap_err().contains("--client-id requires a value"));
1062 }
1063
1064 #[test]
1065 fn test_parse_login_args_bot_scopes_missing_value() {
1066 let args = vec!["--bot-scopes".to_string()];
1067 let result = parse_login_args(&args);
1068 assert!(result.is_err());
1069 assert!(result
1070 .unwrap_err()
1071 .contains("--bot-scopes requires a value"));
1072 }
1073
1074 #[test]
1075 fn test_tunnel_mode_none() {
1076 let mode = TunnelMode::None;
1077 assert!(!mode.is_enabled());
1078 assert!(!mode.is_cloudflared());
1079 assert!(!mode.is_ngrok());
1080 }
1081
1082 #[test]
1083 fn test_tunnel_mode_cloudflared() {
1084 let mode = TunnelMode::Cloudflared(Some("cloudflared".to_string()));
1085 assert!(mode.is_enabled());
1086 assert!(mode.is_cloudflared());
1087 assert!(!mode.is_ngrok());
1088 }
1089
1090 #[test]
1091 fn test_tunnel_mode_ngrok() {
1092 let mode = TunnelMode::Ngrok(Some("ngrok".to_string()));
1093 assert!(mode.is_enabled());
1094 assert!(!mode.is_cloudflared());
1095 assert!(mode.is_ngrok());
1096 }
1097
1098 #[test]
1099 fn test_should_show_private_channel_guidance_empty_response() {
1100 let mut params = HashMap::new();
1101 params.insert("types".to_string(), "private_channel".to_string());
1102
1103 let args = ApiCallArgs {
1104 method: "conversations.list".to_string(),
1105 params,
1106 use_json: false,
1107 use_get: false,
1108 token_type: None,
1109 raw: false,
1110 };
1111
1112 let response = ApiCallResponse {
1113 response: json!({
1114 "ok": true,
1115 "channels": []
1116 }),
1117 meta: ApiCallMeta {
1118 profile_name: Some("default".to_string()),
1119 team_id: "T123".to_string(),
1120 user_id: "U123".to_string(),
1121 method: "conversations.list".to_string(),
1122 command: "api call".to_string(),
1123 token_type: "bot".to_string(),
1124 },
1125 };
1126
1127 assert!(should_show_private_channel_guidance(
1129 &args, "bot", &response
1130 ));
1131 }
1132
1133 #[test]
1134 fn test_should_show_private_channel_guidance_non_empty_response() {
1135 let mut params = HashMap::new();
1136 params.insert("types".to_string(), "private_channel".to_string());
1137
1138 let args = ApiCallArgs {
1139 method: "conversations.list".to_string(),
1140 params,
1141 use_json: false,
1142 use_get: false,
1143 token_type: None,
1144 raw: false,
1145 };
1146
1147 let response = ApiCallResponse {
1148 response: json!({
1149 "ok": true,
1150 "channels": [
1151 {"id": "C123", "name": "private-channel"}
1152 ]
1153 }),
1154 meta: ApiCallMeta {
1155 profile_name: Some("default".to_string()),
1156 team_id: "T123".to_string(),
1157 user_id: "U123".to_string(),
1158 method: "conversations.list".to_string(),
1159 command: "api call".to_string(),
1160 token_type: "bot".to_string(),
1161 },
1162 };
1163
1164 assert!(!should_show_private_channel_guidance(
1166 &args, "bot", &response
1167 ));
1168 }
1169
1170 #[test]
1171 fn test_should_show_private_channel_guidance_user_token() {
1172 let mut params = HashMap::new();
1173 params.insert("types".to_string(), "private_channel".to_string());
1174
1175 let args = ApiCallArgs {
1176 method: "conversations.list".to_string(),
1177 params,
1178 use_json: false,
1179 use_get: false,
1180 token_type: None,
1181 raw: false,
1182 };
1183
1184 let response = ApiCallResponse {
1185 response: json!({
1186 "ok": true,
1187 "channels": []
1188 }),
1189 meta: ApiCallMeta {
1190 profile_name: Some("default".to_string()),
1191 team_id: "T123".to_string(),
1192 user_id: "U123".to_string(),
1193 method: "conversations.list".to_string(),
1194 command: "api call".to_string(),
1195 token_type: "user".to_string(),
1196 },
1197 };
1198
1199 assert!(!should_show_private_channel_guidance(
1201 &args, "user", &response
1202 ));
1203 }
1204
1205 #[test]
1206 fn test_infer_default_token_type_with_user_token() {
1207 let token_store = InMemoryTokenStore::new();
1208 let team_id = "T123";
1209 let user_id = "U456";
1210
1211 token_store
1213 .set(
1214 &format!("{}:{}:user", team_id, user_id),
1215 "xoxp-test-user-token",
1216 )
1217 .unwrap();
1218
1219 let inferred = infer_default_token_type(&token_store, team_id, user_id);
1221 assert_eq!(inferred, TokenType::User);
1222 }
1223
1224 #[test]
1225 fn test_infer_default_token_type_without_user_token() {
1226 let token_store = InMemoryTokenStore::new();
1227 let team_id = "T123";
1228 let user_id = "U456";
1229
1230 token_store
1232 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1233 .unwrap();
1234
1235 let inferred = infer_default_token_type(&token_store, team_id, user_id);
1237 assert_eq!(inferred, TokenType::Bot);
1238 }
1239
1240 #[test]
1241 fn test_infer_default_token_type_with_both_tokens() {
1242 let token_store = InMemoryTokenStore::new();
1243 let team_id = "T123";
1244 let user_id = "U456";
1245
1246 token_store
1248 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1249 .unwrap();
1250 token_store
1251 .set(
1252 &format!("{}:{}:user", team_id, user_id),
1253 "xoxp-test-user-token",
1254 )
1255 .unwrap();
1256
1257 let inferred = infer_default_token_type(&token_store, team_id, user_id);
1259 assert_eq!(inferred, TokenType::User);
1260 }
1261
1262 #[test]
1263 fn test_infer_default_token_type_with_no_tokens() {
1264 let token_store = InMemoryTokenStore::new();
1265 let team_id = "T123";
1266 let user_id = "U456";
1267
1268 let inferred = infer_default_token_type(&token_store, team_id, user_id);
1272 assert_eq!(inferred, TokenType::Bot);
1273 }
1274
1275 #[test]
1276 #[serial]
1277 fn test_resolve_token_with_bot_token_in_store() {
1278 std::env::remove_var("SLACK_TOKEN");
1280
1281 let token_store = InMemoryTokenStore::new();
1282 let team_id = "T123";
1283 let user_id = "U456";
1284
1285 token_store
1287 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1288 .unwrap();
1289
1290 let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
1292
1293 assert!(result.is_ok());
1294 let resolved = result.unwrap();
1295 assert_eq!(resolved.token, "xoxb-test-bot-token");
1296 assert_eq!(resolved.token_type, TokenType::Bot);
1297 }
1298
1299 #[test]
1300 #[serial]
1301 fn test_resolve_token_with_user_token_in_store() {
1302 std::env::remove_var("SLACK_TOKEN");
1304
1305 let token_store = InMemoryTokenStore::new();
1306 let team_id = "T123";
1307 let user_id = "U456";
1308
1309 token_store
1311 .set(
1312 &format!("{}:{}:user", team_id, user_id),
1313 "xoxp-test-user-token",
1314 )
1315 .unwrap();
1316
1317 let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
1319
1320 assert!(result.is_ok());
1321 let resolved = result.unwrap();
1322 assert_eq!(resolved.token, "xoxp-test-user-token");
1323 assert_eq!(resolved.token_type, TokenType::User);
1324 }
1325
1326 #[test]
1327 #[serial]
1328 fn test_resolve_token_with_slack_token_env() {
1329 let token_store = InMemoryTokenStore::new();
1330 let team_id = "T123";
1331 let user_id = "U456";
1332
1333 std::env::set_var("SLACK_TOKEN", "xoxb-env-token");
1335
1336 let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
1338
1339 std::env::remove_var("SLACK_TOKEN");
1340
1341 assert!(result.is_ok());
1342 let resolved = result.unwrap();
1343 assert_eq!(resolved.token, "xoxb-env-token");
1344 assert_eq!(resolved.token_type, TokenType::Bot);
1346 }
1347
1348 #[test]
1349 #[serial]
1350 fn test_resolve_token_explicit_bot_request_fails_without_bot_token() {
1351 std::env::remove_var("SLACK_TOKEN");
1353
1354 let token_store = InMemoryTokenStore::new();
1355 let team_id = "T123";
1356 let user_id = "U456";
1357
1358 token_store
1360 .set(
1361 &format!("{}:{}:user", team_id, user_id),
1362 "xoxp-test-user-token",
1363 )
1364 .unwrap();
1365
1366 let result = resolve_token(
1368 &token_store,
1369 team_id,
1370 user_id,
1371 Some(TokenType::Bot),
1372 None,
1373 "default",
1374 );
1375
1376 assert!(result.is_err());
1377 let error_msg = result.unwrap_err();
1378 assert!(error_msg.contains("No bot token found"));
1379 assert!(error_msg.contains("Explicitly requested token type not available"));
1380 }
1381
1382 #[test]
1383 #[serial]
1384 fn test_resolve_token_explicit_user_request_fails_without_user_token() {
1385 std::env::remove_var("SLACK_TOKEN");
1387
1388 let token_store = InMemoryTokenStore::new();
1389 let team_id = "T123";
1390 let user_id = "U456";
1391
1392 token_store
1394 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1395 .unwrap();
1396
1397 let result = resolve_token(
1399 &token_store,
1400 team_id,
1401 user_id,
1402 Some(TokenType::User),
1403 None,
1404 "default",
1405 );
1406
1407 assert!(result.is_err());
1408 let error_msg = result.unwrap_err();
1409 assert!(error_msg.contains("No user token found"));
1410 assert!(error_msg.contains("Explicitly requested token type not available"));
1411 }
1412
1413 #[test]
1414 #[serial]
1415 fn test_resolve_token_fallback_from_user_to_bot() {
1416 std::env::remove_var("SLACK_TOKEN");
1418
1419 let token_store = InMemoryTokenStore::new();
1420 let team_id = "T123";
1421 let user_id = "U456";
1422
1423 token_store
1425 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1426 .unwrap();
1427
1428 let result = resolve_token(
1446 &token_store,
1447 team_id,
1448 user_id,
1449 None,
1450 Some(TokenType::User), "default",
1452 );
1453
1454 assert!(result.is_err());
1456 }
1457
1458 #[test]
1459 #[serial]
1460 fn test_resolve_token_no_fallback_when_profile_default_set() {
1461 std::env::remove_var("SLACK_TOKEN");
1463
1464 let token_store = InMemoryTokenStore::new();
1465 let team_id = "T123";
1466 let user_id = "U456";
1467
1468 token_store
1470 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1471 .unwrap();
1472
1473 let result = resolve_token(
1475 &token_store,
1476 team_id,
1477 user_id,
1478 None,
1479 Some(TokenType::User),
1480 "default",
1481 );
1482
1483 assert!(result.is_err());
1485 let error_msg = result.unwrap_err();
1486 assert!(error_msg.contains("Explicitly requested token type not available"));
1487 }
1488
1489 #[test]
1490 #[serial]
1491 fn test_resolve_token_cli_overrides_profile_default() {
1492 std::env::remove_var("SLACK_TOKEN");
1494
1495 let token_store = InMemoryTokenStore::new();
1496 let team_id = "T123";
1497 let user_id = "U456";
1498
1499 token_store
1501 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1502 .unwrap();
1503 token_store
1504 .set(
1505 &format!("{}:{}:user", team_id, user_id),
1506 "xoxp-test-user-token",
1507 )
1508 .unwrap();
1509
1510 let result = resolve_token(
1512 &token_store,
1513 team_id,
1514 user_id,
1515 Some(TokenType::User), Some(TokenType::Bot), "default",
1518 );
1519
1520 assert!(result.is_ok());
1521 let resolved = result.unwrap();
1522 assert_eq!(resolved.token, "xoxp-test-user-token");
1523 assert_eq!(resolved.token_type, TokenType::User);
1524 }
1525
1526 #[test]
1527 #[serial]
1528 fn test_resolve_token_slack_token_prioritized_over_store() {
1529 let token_store = InMemoryTokenStore::new();
1530 let team_id = "T123";
1531 let user_id = "U456";
1532
1533 token_store
1535 .set(&format!("{}:{}", team_id, user_id), "xoxb-store-token")
1536 .unwrap();
1537
1538 std::env::set_var("SLACK_TOKEN", "xoxb-env-token");
1540
1541 let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
1542
1543 std::env::remove_var("SLACK_TOKEN");
1545
1546 assert!(result.is_ok());
1547 let resolved = result.unwrap();
1548 assert_eq!(resolved.token, "xoxb-env-token");
1550 assert_eq!(resolved.token_type, TokenType::Bot);
1551 }
1552
1553 #[test]
1554 #[serial]
1555 fn test_resolve_token_with_both_tokens_prefers_user() {
1556 std::env::remove_var("SLACK_TOKEN");
1558
1559 let token_store = InMemoryTokenStore::new();
1560 let team_id = "T123";
1561 let user_id = "U456";
1562
1563 token_store
1565 .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1566 .unwrap();
1567 token_store
1568 .set(
1569 &format!("{}:{}:user", team_id, user_id),
1570 "xoxp-test-user-token",
1571 )
1572 .unwrap();
1573
1574 let result = resolve_token(&token_store, team_id, user_id, None, None, "default");
1576
1577 assert!(result.is_ok());
1578 let resolved = result.unwrap();
1579 assert_eq!(resolved.token, "xoxp-test-user-token");
1581 assert_eq!(resolved.token_type, TokenType::User);
1582 }
1583}