cc_switch_lib/deeplink/
parser.rs1use super::utils::validate_url;
6use super::DeepLinkImportRequest;
7use crate::error::AppError;
8use std::collections::HashMap;
9use url::Url;
10
11pub 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(¶ms, 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}