slack-rs 0.1.70

A Slack CLI tool with OAuth authentication, profile management, and API access
Documentation
//! Slack App Manifest generation
//!
//! This module provides functionality to generate Slack App Manifest YAML files
//! from OAuth configuration and scope information.

use serde::{Deserialize, Serialize};

/// Slack App Manifest structure
#[derive(Debug, Serialize, Deserialize)]
pub struct AppManifest {
    #[serde(rename = "_metadata")]
    pub _metadata: Metadata,
    pub display_information: DisplayInformation,
    pub features: Features,
    pub oauth_config: OAuthConfig,
    pub settings: Settings,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Metadata {
    pub major_version: u32,
    pub minor_version: u32,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct DisplayInformation {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub background_color: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Features {
    pub bot_user: BotUser,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct BotUser {
    pub display_name: String,
    pub always_online: bool,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct OAuthConfig {
    pub redirect_urls: Vec<String>,
    pub scopes: Scopes,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Scopes {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bot: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub user: Option<Vec<String>>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Settings {
    pub org_deploy_enabled: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub socket_mode_enabled: Option<bool>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub token_rotation_enabled: Option<bool>,
}

/// Generate Slack App Manifest YAML from OAuth configuration
///
/// # Arguments
/// * `bot_scopes` - Bot OAuth scopes
/// * `user_scopes` - User OAuth scopes
/// * `redirect_uri` - OAuth redirect URI
/// * `profile_name` - Profile name (used for bot display name)
///
/// # Returns
/// YAML string representation of the Slack App Manifest
pub fn generate_manifest(
    bot_scopes: &[String],
    user_scopes: &[String],
    redirect_uri: &str,
    profile_name: &str,
) -> Result<String, String> {
    // Determine redirect URLs based on whether cloudflared or ngrok is used
    // Note: Slack does not accept wildcard URLs in manifests, so we only include the actual redirect_uri
    let redirect_urls = vec![redirect_uri.to_string()];

    let manifest = AppManifest {
        _metadata: Metadata {
            major_version: 2,
            minor_version: 1,
        },
        display_information: DisplayInformation {
            name: format!("slack-rs ({})", profile_name),
            description: Some(format!(
                "Slack CLI application for profile '{}'",
                profile_name
            )),
            background_color: Some("#2c2d30".to_string()),
        },
        features: Features {
            bot_user: BotUser {
                display_name: format!("slack-rs-{}", profile_name),
                always_online: false,
            },
        },
        oauth_config: OAuthConfig {
            redirect_urls,
            scopes: Scopes {
                bot: if bot_scopes.is_empty() {
                    None
                } else {
                    Some(bot_scopes.to_vec())
                },
                user: if user_scopes.is_empty() {
                    None
                } else {
                    Some(user_scopes.to_vec())
                },
            },
        },
        settings: Settings {
            org_deploy_enabled: false,
            socket_mode_enabled: Some(false),
            token_rotation_enabled: Some(false),
        },
    };

    // Serialize to YAML with explicit configuration
    let yaml_string = serde_yaml::to_string(&manifest)
        .map_err(|e| format!("Failed to serialize manifest: {}", e))?;

    // Verify the YAML starts correctly
    if !yaml_string.starts_with("_metadata:") && !yaml_string.starts_with("\"_metadata\":") {
        return Err(format!(
            "Generated YAML does not start with _metadata field. First line: {}",
            yaml_string.lines().next().unwrap_or("(empty)")
        ));
    }

    Ok(yaml_string)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_generate_manifest_with_bot_scopes_only() {
        let bot_scopes = vec!["chat:write".to_string(), "users:read".to_string()];
        let user_scopes = vec![];
        let result = generate_manifest(
            &bot_scopes,
            &user_scopes,
            "http://localhost:8765/callback",
            "default",
        );

        assert!(result.is_ok());
        let yaml = result.unwrap();

        // Print YAML for debugging
        println!("Generated YAML:\n{}", yaml);

        // Verify YAML structure
        assert!(yaml.contains("_metadata:"));
        assert!(yaml.contains("major_version: 2"));
        assert!(yaml.contains("minor_version: 1"));
        assert!(yaml.contains("display_information:"));
        assert!(yaml.contains("features:"));
        assert!(yaml.contains("oauth_config:"));
        assert!(yaml.contains("settings:"));

        // Verify scopes
        assert!(yaml.contains("chat:write"));
        assert!(yaml.contains("users:read"));
        assert!(yaml.contains("http://localhost:8765/callback"));
        assert!(yaml.contains("slack-rs (default)"));
        assert!(yaml.contains("bot:"));
        assert!(yaml.contains("scopes:"));

        // Verify YAML can be parsed back
        let parsed: Result<AppManifest, _> = serde_yaml::from_str(&yaml);
        assert!(
            parsed.is_ok(),
            "Generated YAML should be valid and parseable"
        );
    }

    #[test]
    fn test_generate_manifest_with_cloudflared() {
        let bot_scopes = vec!["chat:write".to_string()];
        let user_scopes = vec!["search:read".to_string()];
        let result = generate_manifest(
            &bot_scopes,
            &user_scopes,
            "http://localhost:8765/callback",
            "work",
        );

        assert!(result.is_ok());
        let yaml = result.unwrap();
        // Wildcard URLs are not supported by Slack, so only the actual redirect_uri is included
        assert!(yaml.contains("http://localhost:8765/callback"));
        assert!(yaml.contains("chat:write"));
        assert!(yaml.contains("search:read"));
    }

    #[test]
    fn test_generate_manifest_with_user_scopes() {
        let bot_scopes = vec!["chat:write".to_string()];
        let user_scopes = vec!["users:read".to_string(), "search:read".to_string()];
        let result = generate_manifest(
            &bot_scopes,
            &user_scopes,
            "http://localhost:8765/callback",
            "personal",
        );

        assert!(result.is_ok());
        let yaml = result.unwrap();
        assert!(yaml.contains("chat:write"));
        assert!(yaml.contains("users:read"));
        assert!(yaml.contains("search:read"));
        assert!(yaml.contains("bot:"));
        assert!(yaml.contains("user:"));
    }

    #[test]
    fn test_generate_manifest_empty_scopes() {
        let bot_scopes = vec![];
        let user_scopes = vec![];
        let result = generate_manifest(
            &bot_scopes,
            &user_scopes,
            "http://localhost:8765/callback",
            "empty",
        );

        // Should still generate a valid manifest even with empty scopes
        assert!(result.is_ok());
    }

    #[test]
    fn test_generate_manifest_with_ngrok() {
        let bot_scopes = vec!["chat:write".to_string()];
        let user_scopes = vec!["search:read".to_string()];
        let result = generate_manifest(
            &bot_scopes,
            &user_scopes,
            "http://localhost:8765/callback",
            "ngrok-test",
        );

        assert!(result.is_ok());
        let yaml = result.unwrap();
        // Wildcard URLs are not supported by Slack, so only the actual redirect_uri is included
        assert!(yaml.contains("http://localhost:8765/callback"));
        assert!(yaml.contains("chat:write"));
        assert!(yaml.contains("search:read"));
    }
}