Skip to main content

slack_rs/cli/
handlers.rs

1//! CLI command handlers
2//!
3//! This module contains handler functions for CLI commands that were extracted from main.rs
4//! to improve code organization and maintainability.
5
6use 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/// Parsed login arguments structure
15#[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/// Tunnel mode for login
25#[derive(Debug, Clone, PartialEq)]
26pub enum TunnelMode {
27    None,
28    Cloudflared(Option<String>),
29    Ngrok(Option<String>),
30}
31
32impl TunnelMode {
33    /// Check if tunnel mode is enabled
34    pub fn is_enabled(&self) -> bool {
35        !matches!(self, TunnelMode::None)
36    }
37
38    /// Check if cloudflared is enabled
39    pub fn is_cloudflared(&self) -> bool {
40        matches!(self, TunnelMode::Cloudflared(_))
41    }
42
43    /// Check if ngrok is enabled
44    #[allow(dead_code)]
45    pub fn is_ngrok(&self) -> bool {
46        matches!(self, TunnelMode::Ngrok(_))
47    }
48}
49
50/// Parse login command arguments
51///
52/// This function extracts and validates arguments for the `auth login` command.
53/// It enforces mutual exclusivity between --cloudflared and --ngrok flags.
54///
55/// # Arguments
56/// * `args` - Raw command line arguments after "auth login"
57///
58/// # Returns
59/// * `Ok(LoginArgs)` - Successfully parsed and validated arguments
60/// * `Err(String)` - Parse error with descriptive message
61///
62/// # Validation Rules
63/// 1. --cloudflared and --ngrok are mutually exclusive
64/// 2. Unknown options are rejected
65/// 3. Scope inputs are normalized (comma-separated, whitespace-trimmed)
66pub 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                    // Check if next arg is a value (not starting with --) or end of args
88                    if i + 1 < args.len() && !args[i + 1].starts_with("--") {
89                        i += 1;
90                        cloudflared_path = Some(args[i].clone());
91                    } else {
92                        // Use default "cloudflared" (PATH resolution)
93                        cloudflared_path = Some("cloudflared".to_string());
94                    }
95                }
96                "--ngrok" => {
97                    // Check if next arg is a value (not starting with --) or end of args
98                    if i + 1 < args.len() && !args[i + 1].starts_with("--") {
99                        i += 1;
100                        ngrok_path = Some(args[i].clone());
101                    } else {
102                        // Use default "ngrok" (PATH resolution)
103                        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                        // Expand 'all' presets with bot context (true)
112                        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                        // Expand 'all' presets with user context (false)
123                        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    // Check for conflicting options
141    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    // Determine tunnel mode
146    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
163/// Run the auth login command with argument parsing
164pub async fn run_auth_login(args: &[String], non_interactive: bool) -> Result<(), String> {
165    // Parse arguments
166    let parsed_args = parse_login_args(args)?;
167
168    // Use default redirect_uri
169    let redirect_uri = "http://127.0.0.1:8765/callback".to_string();
170
171    // Keep base_url from environment for testing purposes only
172    let base_url = std::env::var("SLACK_OAUTH_BASE_URL").ok();
173
174    // If cloudflared or ngrok is specified, use extended login flow
175    if parsed_args.tunnel_mode.is_enabled() {
176        // Collect missing parameters in non-interactive mode
177        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        // Prompt for client_id if not provided (only in interactive mode)
199        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        // Use default scopes if not provided
215        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        // Prompt for client_secret (only in interactive mode)
227        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        // Call extended login with cloudflared support
235        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        // Call standard login with credentials
247        // This will prompt for client_secret and other missing OAuth config
248        auth::login_with_credentials(
249            parsed_args.client_id,
250            parsed_args.profile_name,
251            redirect_uri,
252            vec![], // Legacy scopes parameter (unused)
253            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
263/// Check if we should show private channel guidance
264fn should_show_private_channel_guidance(
265    api_args: &ApiCallArgs,
266    token_type: &str,
267    response: &ApiCallResponse,
268) -> bool {
269    // Only show guidance for conversations.list with private_channel type and bot token
270    if api_args.method != "conversations.list" || token_type != "bot" {
271        return false;
272    }
273
274    // Check if types parameter includes private_channel
275    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    // Check if response has empty channels array
284    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
293/// Infer the default token type based on token store existence
294/// Returns User if a user token exists, otherwise Bot
295fn 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/// Result of token resolution containing the token and its type
309#[derive(Debug)]
310struct ResolvedToken {
311    token: String,
312    token_type: TokenType,
313}
314
315/// Resolves and retrieves the appropriate token for an API call
316///
317/// This function encapsulates the token resolution logic:
318/// 1. Determines token type: CLI flag > profile default > inferred (user if exists, else bot)
319/// 2. Attempts to retrieve token from token store
320/// 3. Falls back to SLACK_TOKEN environment variable if store retrieval fails
321/// 4. If explicit token type was requested and not found, returns error
322/// 5. If no explicit preference, falls back from user to bot token
323///
324/// # Arguments
325/// * `token_store` - Token store to retrieve tokens from
326/// * `team_id` - Team ID for token key construction
327/// * `user_id` - User ID for token key construction
328/// * `cli_token_type` - Optional token type from CLI flag (--token-type)
329/// * `profile_default_token_type` - Optional default token type from profile config
330/// * `profile_name` - Profile name for error messages
331///
332/// # Returns
333/// * `Ok(ResolvedToken)` - Successfully resolved token and its type
334/// * `Err(String)` - Error message describing why token resolution failed
335fn 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    // Infer default token type based on user token existence
344    let inferred_default = infer_default_token_type(token_store, team_id, user_id);
345
346    // Resolve token type: CLI flag > profile default > inferred default
347    let resolved_token_type =
348        TokenType::resolve(cli_token_type, profile_default_token_type, inferred_default);
349
350    // Create token keys for both bot and user tokens
351    let token_key_bot = make_token_key(team_id, user_id);
352    let token_key_user = format!("{}:{}:user", team_id, user_id);
353
354    // Select the appropriate token key based on resolved token type
355    let token_key = match resolved_token_type {
356        TokenType::Bot => token_key_bot.clone(),
357        TokenType::User => token_key_user.clone(),
358    };
359
360    // Determine if the token type was explicitly requested via CLI flag OR default_token_type
361    // If either is set, we should NOT fallback to a different token type
362    let explicit_request = cli_token_type.is_some() || profile_default_token_type.is_some();
363
364    // PRIORITY 1: Check SLACK_TOKEN environment variable first (highest priority)
365    let token = if let Ok(env_token) = std::env::var("SLACK_TOKEN") {
366        env_token
367    } else {
368        // PRIORITY 2: Try to retrieve token from token store
369        match token_store.get(&token_key) {
370            Ok(t) => t,
371            Err(_) => {
372                // PRIORITY 3: If token not found in store, apply fallback logic
373                if explicit_request {
374                    // If token type was explicitly requested, fail without fallback
375                    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 no token type preference was specified, try bot token as fallback
381                    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
414/// Run the api call command
415pub async fn run_api_call(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
416    // Parse arguments
417    let api_args = ApiCallArgs::parse(&args)?;
418
419    // Resolve profile name using common helper (--profile > SLACK_PROFILE > "default")
420    let profile_name = crate::cli::resolve_profile_name(&args);
421
422    // Get config path
423    let config_path = default_config_path()?;
424
425    // Resolve profile to get full profile details
426    let profile = resolve_profile_full(&config_path, &profile_name)
427        .map_err(|e| format!("Failed to resolve profile '{}': {}", profile_name, e))?;
428
429    // Create context from resolved profile
430    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    // Create token store to check token existence for inference
437    let token_store =
438        create_token_store().map_err(|e| format!("Failed to create token store: {}", e))?;
439
440    // Resolve token using dedicated function
441    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    // Get debug level from args
455    let debug_level = debug::get_debug_level(&args);
456
457    // Log debug information if --debug or --trace flag is present
458    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    // Create API client
476    let client = ApiClient::new();
477
478    // Execute API call with token type information and command name
479    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    // Log error code if present
490    debug::log_error_code(debug_level, &response.response);
491
492    // Display error guidance if response contains a known error
493    crate::api::display_error_guidance(&response);
494
495    // Check if we should show guidance for private_channel with bot token
496    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    // Print response as JSON
506    // If --raw flag is set or SLACKRS_OUTPUT=raw, output only the Slack API response without envelope
507    // Note: api_args.raw already accounts for both --raw flag and SLACKRS_OUTPUT env via should_output_raw()
508    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
518/// Common arguments shared between export and import commands
519struct ExportImportArgs {
520    passphrase_env: Option<String>,
521    yes: bool,
522    lang: Option<String>,
523}
524
525impl ExportImportArgs {
526    /// Parse common arguments from command line args
527    /// Returns (ExportImportArgs, remaining_unparsed_args)
528    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                    // Ignore this flag - we always prompt if --passphrase-env is not set
545                }
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                    // Not a common argument, save for specific parsing
557                    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    /// Get Messages based on language setting
574    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    /// Get passphrase from environment variable or prompt
587    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                    // Fallback to prompt if environment variable is not set
593                    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            // Fallback to prompt mode
603            rpassword::prompt_password(messages.get("prompt.passphrase"))
604                .map_err(|e| format!("Error reading passphrase: {}", e))
605        }
606    }
607}
608
609/// Handle auth export command
610pub async fn handle_export_command(args: &[String]) {
611    // Check for help flags first
612    if args.iter().any(|arg| arg == "-h" || arg == "--help") {
613        super::help::print_export_help();
614        return;
615    }
616
617    // Parse common arguments
618    let (common_args, remaining) = ExportImportArgs::parse(args);
619
620    // Parse export-specific arguments
621    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                // Next arg should be the profile name
629                if idx + 1 < args.len() {
630                    profile_name = Some(args[idx + 1].clone());
631                }
632            }
633            "--all" => {
634                all = true;
635            }
636            "--out" => {
637                // Next arg should be the output path
638                if idx + 1 < args.len() {
639                    output_path = Some(args[idx + 1].clone());
640                }
641            }
642            _ => {
643                // Check if this is a value for a previous flag
644                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                        // This is a value, not an unknown option
652                        continue;
653                    }
654                }
655                eprintln!("Unknown option: {}", arg);
656                std::process::exit(1);
657            }
658        }
659    }
660
661    // Get i18n messages
662    let messages = common_args.get_messages();
663
664    // Show warning and validate --yes
665    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    // Validate required options
672    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    // Get passphrase
681    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            // Show warnings for skipped profiles
701            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
725/// Handle auth import command
726pub async fn handle_import_command(args: &[String]) {
727    // Check for help flags first
728    if args.iter().any(|arg| arg == "-h" || arg == "--help") {
729        super::help::print_import_help();
730        return;
731    }
732
733    // Parse common arguments
734    let (common_args, remaining) = ExportImportArgs::parse(args);
735
736    // Parse import-specific arguments
737    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                // Next arg should be the input path
746                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                // Check if this is a value for a previous flag
761                if idx > 0 {
762                    let prev = &args[idx - 1];
763                    if prev == "--in" || prev == "--passphrase-env" || prev == "--lang" {
764                        // This is a value, not an unknown option
765                        continue;
766                    }
767                }
768                eprintln!("Unknown option: {}", arg);
769                std::process::exit(1);
770            }
771        }
772    }
773
774    // Get i18n messages
775    let messages = common_args.get_messages();
776
777    // Validate required options
778    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    // Get passphrase
787    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                // Output JSON format
809                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                // Output text format
820                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
854/// Run install-skills command
855///
856/// # Arguments
857/// * `args` - Command line arguments (may include source)
858///
859/// # Returns
860/// * `Ok(())` - Success (JSON output to stdout)
861/// * `Err(String)` - Error (error message to stderr, non-zero exit)
862pub 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    // Extract source argument (first non-flag argument, or None for default)
869    let source = args
870        .iter()
871        .find(|arg| !arg.starts_with("--"))
872        .map(|s| s.as_str());
873
874    // Install skill
875    let installed = skills::install_skill(source, global).map_err(|e| e.to_string())?;
876
877    // Build JSON response
878    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    // Output JSON to stdout
892    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        // Should show guidance when bot token returns empty private channels
1128        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        // Should not show guidance when channels are returned
1165        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        // Should not show guidance when using user token
1200        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        // Set a user token
1212        token_store
1213            .set(
1214                &format!("{}:{}:user", team_id, user_id),
1215                "xoxp-test-user-token",
1216            )
1217            .unwrap();
1218
1219        // Should infer User when user token exists
1220        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        // Set only a bot token
1231        token_store
1232            .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1233            .unwrap();
1234
1235        // Should infer Bot when user token does not exist
1236        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        // Set both tokens
1247        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        // Should infer User when user token exists (even if bot token also exists)
1258        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        // No tokens set
1269
1270        // Should infer Bot when no tokens exist
1271        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1279        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        // Set a bot token
1286        token_store
1287            .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1288            .unwrap();
1289
1290        // Resolve token with no CLI or profile preference
1291        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1303        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        // Set a user token
1310        token_store
1311            .set(
1312                &format!("{}:{}:user", team_id, user_id),
1313                "xoxp-test-user-token",
1314            )
1315            .unwrap();
1316
1317        // Resolve token with no CLI or profile preference
1318        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        // Set SLACK_TOKEN environment variable
1334        std::env::set_var("SLACK_TOKEN", "xoxb-env-token");
1335
1336        // Resolve token with no tokens in store
1337        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        // Token type should be Bot (inferred default when no tokens exist)
1345        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1352        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        // Set only a user token
1359        token_store
1360            .set(
1361                &format!("{}:{}:user", team_id, user_id),
1362                "xoxp-test-user-token",
1363            )
1364            .unwrap();
1365
1366        // Explicitly request bot token via CLI flag
1367        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1386        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        // Set only a bot token
1393        token_store
1394            .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1395            .unwrap();
1396
1397        // Explicitly request user token via CLI flag
1398        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1417        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        // Set only a bot token
1424        token_store
1425            .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1426            .unwrap();
1427
1428        // No explicit request (user token is inferred default when it doesn't exist -> Bot)
1429        // But if user token were to be the inferred default and not found, it should fallback
1430        // Let me test the actual fallback scenario
1431
1432        // Actually, the fallback only happens when resolved type is User and no explicit request
1433        // Since there's no user token, inferred default will be Bot anyway
1434        // To test fallback, I need to simulate a case where User is resolved but not found
1435
1436        // This is not possible with the current logic because if user token doesn't exist,
1437        // inferred_default will be Bot. The fallback case only triggers when:
1438        // - resolved_token_type == TokenType::User
1439        // - explicit_request == false
1440        // - user token not in store and SLACK_TOKEN not set
1441
1442        // For this to happen, we'd need profile.default_token_type to be User but no user token
1443        // Let me create that scenario:
1444
1445        let result = resolve_token(
1446            &token_store,
1447            team_id,
1448            user_id,
1449            None,
1450            Some(TokenType::User), // Profile says use User
1451            "default",
1452        );
1453
1454        // This should fail because profile explicitly requested User token
1455        assert!(result.is_err());
1456    }
1457
1458    #[test]
1459    #[serial]
1460    fn test_resolve_token_no_fallback_when_profile_default_set() {
1461        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1462        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        // Set only a bot token
1469        token_store
1470            .set(&format!("{}:{}", team_id, user_id), "xoxb-test-bot-token")
1471            .unwrap();
1472
1473        // Profile default is User (explicit request)
1474        let result = resolve_token(
1475            &token_store,
1476            team_id,
1477            user_id,
1478            None,
1479            Some(TokenType::User),
1480            "default",
1481        );
1482
1483        // Should fail without fallback because profile explicitly requested User
1484        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1493        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        // Set both tokens
1500        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        // Profile default is Bot, but CLI requests User
1511        let result = resolve_token(
1512            &token_store,
1513            team_id,
1514            user_id,
1515            Some(TokenType::User), // CLI flag
1516            Some(TokenType::Bot),  // Profile default
1517            "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        // Set a bot token in store
1534        token_store
1535            .set(&format!("{}:{}", team_id, user_id), "xoxb-store-token")
1536            .unwrap();
1537
1538        // Set SLACK_TOKEN environment variable
1539        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        // Clean up environment variable
1544        std::env::remove_var("SLACK_TOKEN");
1545
1546        assert!(result.is_ok());
1547        let resolved = result.unwrap();
1548        // Should use env token (SLACK_TOKEN), NOT store token
1549        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        // Ensure no SLACK_TOKEN env var is set (cleanup from other tests)
1557        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        // Set both tokens
1564        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        // No explicit preference
1575        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        // Should prefer User when both exist
1580        assert_eq!(resolved.token, "xoxp-test-user-token");
1581        assert_eq!(resolved.token_type, TokenType::User);
1582    }
1583}