refactor(deeplink): split monolithic deeplink.rs into modular structure

Split the 1691-line deeplink.rs into a well-organized module directory:

- mod.rs (120 lines): DeepLinkImportRequest struct and public exports
- parser.rs (321 lines): URL parsing logic for all resource types
- provider.rs (510 lines): Provider import and config merge logic
- mcp.rs (191 lines): MCP server batch import
- prompt.rs (86 lines): Prompt import
- skill.rs (52 lines): Skill repository import
- utils.rs (99 lines): Shared utilities (URL validation, Base64 decoding)
- tests.rs (384 lines): All unit tests

Benefits:
- Max file size reduced from 1691 to 510 lines
- Each resource type has its own import module
- Shared utilities extracted for reuse
- All 17 deeplink tests + 107 total tests passing
This commit is contained in:
Jason
2025-11-25 12:05:33 +08:00
parent c3f2f0798d
commit 87190b7af3
9 changed files with 1763 additions and 1691 deletions
+321
View File
@@ -0,0 +1,321 @@
//! Deep link URL parser
//!
//! Parses ccswitch:// URLs into DeepLinkImportRequest structures.
use super::utils::validate_url;
use super::DeepLinkImportRequest;
use crate::error::AppError;
use std::collections::HashMap;
use url::Url;
/// Parse a ccswitch:// URL into a DeepLinkImportRequest
///
/// Expected format:
/// ccswitch://v1/import?resource={type}&...
pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
// Parse URL
let url = Url::parse(url_str)
.map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
// Validate scheme
let scheme = url.scheme();
if scheme != "ccswitch" {
return Err(AppError::InvalidInput(format!(
"Invalid scheme: expected 'ccswitch', got '{scheme}'"
)));
}
// Extract version from host
let version = url
.host_str()
.ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
.to_string();
// Validate version
if version != "v1" {
return Err(AppError::InvalidInput(format!(
"Unsupported protocol version: {version}"
)));
}
// Extract path (should be "/import")
let path = url.path();
if path != "/import" {
return Err(AppError::InvalidInput(format!(
"Invalid path: expected '/import', got '{path}'"
)));
}
// Parse query parameters
let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
// Extract and validate resource type
let resource = params
.get("resource")
.ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
.clone();
// Dispatch to appropriate parser based on resource type
match resource.as_str() {
"provider" => parse_provider_deeplink(&params, version, resource),
"prompt" => parse_prompt_deeplink(&params, version, resource),
"mcp" => parse_mcp_deeplink(&params, version, resource),
"skill" => parse_skill_deeplink(&params, version, resource),
_ => Err(AppError::InvalidInput(format!(
"Unsupported resource type: {resource}"
))),
}
}
/// Parse provider deep link parameters
fn parse_provider_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
.clone();
// Make these optional for config file auto-fill (v3.8+)
let homepage = params.get("homepage").cloned();
let endpoint = params.get("endpoint").cloned();
let api_key = params.get("apiKey").cloned();
// Validate URLs only if provided
if let Some(ref hp) = homepage {
if !hp.is_empty() {
validate_url(hp, "homepage")?;
}
}
if let Some(ref ep) = endpoint {
if !ep.is_empty() {
validate_url(ep, "endpoint")?;
}
}
// Extract optional fields
let model = params.get("model").cloned();
let notes = params.get("notes").cloned();
let haiku_model = params.get("haikuModel").cloned();
let sonnet_model = params.get("sonnetModel").cloned();
let opus_model = params.get("opusModel").cloned();
let icon = params
.get("icon")
.map(|v| v.trim().to_lowercase())
.filter(|v| !v.is_empty());
let config = params.get("config").cloned();
let config_format = params.get("configFormat").cloned();
let config_url = params.get("configUrl").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
app: Some(app),
name: Some(name),
enabled,
homepage,
endpoint,
api_key,
icon,
model,
notes,
haiku_model,
sonnet_model,
opus_model,
content: None,
description: None,
apps: None,
repo: None,
directory: None,
branch: None,
skills_path: None,
config,
config_format,
config_url,
})
}
/// Parse prompt deep link parameters
fn parse_prompt_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let app = params
.get("app")
.ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter for prompt".to_string()))?
.clone();
// Validate app type
if app != "claude" && app != "codex" && app != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', or 'gemini', got '{app}'"
)));
}
let name = params
.get("name")
.ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter for prompt".to_string()))?
.clone();
let content = params
.get("content")
.ok_or_else(|| {
AppError::InvalidInput("Missing 'content' parameter for prompt".to_string())
})?
.clone();
let description = params.get("description").cloned();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
app: Some(app),
name: Some(name),
enabled,
content: Some(content),
description,
icon: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
apps: None,
repo: None,
directory: None,
branch: None,
skills_path: None,
config: None,
config_format: None,
config_url: None,
})
}
/// Parse MCP deep link parameters
fn parse_mcp_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let apps = params
.get("apps")
.ok_or_else(|| AppError::InvalidInput("Missing 'apps' parameter for MCP".to_string()))?
.clone();
// Validate apps format
for app in apps.split(',') {
let trimmed = app.trim();
if trimmed != "claude" && trimmed != "codex" && trimmed != "gemini" {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', or 'gemini', got '{trimmed}'"
)));
}
}
let config = params
.get("config")
.ok_or_else(|| AppError::InvalidInput("Missing 'config' parameter for MCP".to_string()))?
.clone();
let enabled = params.get("enabled").and_then(|v| v.parse::<bool>().ok());
Ok(DeepLinkImportRequest {
version,
resource,
apps: Some(apps),
enabled,
config: Some(config),
config_format: Some("json".to_string()), // MCP config is always JSON
app: None,
name: None,
icon: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
content: None,
description: None,
repo: None,
directory: None,
branch: None,
skills_path: None,
config_url: None,
})
}
/// Parse skill deep link parameters
fn parse_skill_deeplink(
params: &HashMap<String, String>,
version: String,
resource: String,
) -> Result<DeepLinkImportRequest, AppError> {
let repo = params
.get("repo")
.ok_or_else(|| AppError::InvalidInput("Missing 'repo' parameter for skill".to_string()))?
.clone();
// Validate repo format (should be "owner/name")
if !repo.contains('/') || repo.split('/').count() != 2 {
return Err(AppError::InvalidInput(format!(
"Invalid repo format: expected 'owner/name', got '{repo}'"
)));
}
let directory = params.get("directory").cloned();
let branch = params.get("branch").cloned();
let skills_path = params
.get("skills_path")
.or_else(|| params.get("skillsPath"))
.cloned();
Ok(DeepLinkImportRequest {
version,
resource,
repo: Some(repo),
directory,
branch,
skills_path,
icon: None,
app: Some("claude".to_string()), // Skills are Claude-only
name: None,
enabled: None,
homepage: None,
endpoint: None,
api_key: None,
model: None,
notes: None,
haiku_model: None,
sonnet_model: None,
opus_model: None,
content: None,
description: None,
apps: None,
config: None,
config_format: None,
config_url: None,
})
}