Skip to main content

cc_switch_lib/deeplink/
parser.rs

1//! Deep link URL parser.
2//!
3//! Parses `ccswitch://` URLs into `DeepLinkImportRequest` structures.
4
5use super::utils::validate_url;
6use super::DeepLinkImportRequest;
7use crate::error::AppError;
8use std::collections::HashMap;
9use url::Url;
10
11/// Parse a `ccswitch://` URL into a `DeepLinkImportRequest`.
12///
13/// Expected format:
14/// `ccswitch://v1/import?resource=provider&...`
15pub fn parse_deeplink_url(url_str: &str) -> Result<DeepLinkImportRequest, AppError> {
16    let url = Url::parse(url_str)
17        .map_err(|e| AppError::InvalidInput(format!("Invalid deep link URL: {e}")))?;
18
19    let scheme = url.scheme();
20    if scheme != "ccswitch" {
21        return Err(AppError::InvalidInput(format!(
22            "Invalid scheme: expected 'ccswitch', got '{scheme}'"
23        )));
24    }
25
26    let version = url
27        .host_str()
28        .ok_or_else(|| AppError::InvalidInput("Missing version in URL host".to_string()))?
29        .to_string();
30    if version != "v1" {
31        return Err(AppError::InvalidInput(format!(
32            "Unsupported protocol version: {version}"
33        )));
34    }
35
36    let path = url.path();
37    if path != "/import" {
38        return Err(AppError::InvalidInput(format!(
39            "Invalid path: expected '/import', got '{path}'"
40        )));
41    }
42
43    let params: HashMap<String, String> = url.query_pairs().into_owned().collect();
44    let resource = params
45        .get("resource")
46        .ok_or_else(|| AppError::InvalidInput("Missing 'resource' parameter".to_string()))?
47        .clone();
48
49    match resource.as_str() {
50        "provider" => parse_provider_deeplink(&params, version, resource),
51        _ => Err(AppError::InvalidInput(format!(
52            "Unsupported resource type: {resource}"
53        ))),
54    }
55}
56
57fn parse_provider_deeplink(
58    params: &HashMap<String, String>,
59    version: String,
60    resource: String,
61) -> Result<DeepLinkImportRequest, AppError> {
62    let app = params
63        .get("app")
64        .ok_or_else(|| AppError::InvalidInput("Missing 'app' parameter".to_string()))?
65        .clone();
66
67    if app != "claude"
68        && app != "codex"
69        && app != "gemini"
70        && app != "opencode"
71        && app != "openclaw"
72    {
73        return Err(AppError::InvalidInput(format!(
74            "Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
75        )));
76    }
77
78    let name = params
79        .get("name")
80        .ok_or_else(|| AppError::InvalidInput("Missing 'name' parameter".to_string()))?
81        .clone();
82
83    let homepage = params.get("homepage").cloned();
84    let endpoint = params.get("endpoint").cloned();
85    let api_key = params.get("apiKey").cloned();
86
87    if let Some(ref hp) = homepage {
88        if !hp.is_empty() {
89            validate_url(hp, "homepage")?;
90        }
91    }
92
93    if let Some(ref ep) = endpoint {
94        for (i, url) in ep.split(',').enumerate() {
95            let trimmed = url.trim();
96            if !trimmed.is_empty() {
97                validate_url(trimmed, &format!("endpoint[{i}]"))?;
98            }
99        }
100    }
101
102    Ok(DeepLinkImportRequest {
103        version,
104        resource,
105        app: Some(app),
106        name: Some(name),
107        enabled: params.get("enabled").and_then(|v| v.parse::<bool>().ok()),
108        homepage,
109        endpoint,
110        api_key,
111        icon: params
112            .get("icon")
113            .map(|v| v.trim().to_lowercase())
114            .filter(|v| !v.is_empty()),
115        model: params.get("model").cloned(),
116        notes: params.get("notes").cloned(),
117        haiku_model: params.get("haikuModel").cloned(),
118        sonnet_model: params.get("sonnetModel").cloned(),
119        opus_model: params.get("opusModel").cloned(),
120        content: None,
121        description: None,
122        apps: None,
123        repo: None,
124        directory: None,
125        branch: None,
126        config: params.get("config").cloned(),
127        config_format: params.get("configFormat").cloned(),
128        config_url: params.get("configUrl").cloned(),
129        usage_enabled: params
130            .get("usageEnabled")
131            .and_then(|v| v.parse::<bool>().ok()),
132        usage_script: params.get("usageScript").cloned(),
133        usage_api_key: params.get("usageApiKey").cloned(),
134        usage_base_url: params.get("usageBaseUrl").cloned(),
135        usage_access_token: params.get("usageAccessToken").cloned(),
136        usage_user_id: params.get("usageUserId").cloned(),
137        usage_auto_interval: params
138            .get("usageAutoInterval")
139            .and_then(|v| v.parse::<u64>().ok()),
140        openclaw_config: None,
141    })
142}