use serde::{Deserialize, Serialize};
#[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>,
}
pub fn generate_manifest(
bot_scopes: &[String],
user_scopes: &[String],
redirect_uri: &str,
profile_name: &str,
) -> Result<String, String> {
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),
},
};
let yaml_string = serde_yaml::to_string(&manifest)
.map_err(|e| format!("Failed to serialize manifest: {}", e))?;
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();
println!("Generated YAML:\n{}", yaml);
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:"));
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:"));
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();
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",
);
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();
assert!(yaml.contains("http://localhost:8765/callback"));
assert!(yaml.contains("chat:write"));
assert!(yaml.contains("search:read"));
}
}