1use crate::AppState;
4use axum::Json;
5use axum::extract::State;
6use axum::http::StatusCode;
7use axum::response::IntoResponse;
8use scope::config::Config;
9use serde::Deserialize;
10use std::sync::Arc;
11
12pub async fn handle(State(state): State<Arc<AppState>>) -> impl IntoResponse {
14 let config = &state.config;
15 let config_path = Config::config_path()
16 .map(|p| p.display().to_string())
17 .unwrap_or_else(|| "unknown".to_string());
18
19 let config_exists = Config::config_path().map(|p| p.exists()).unwrap_or(false);
20
21 let api_keys_status: serde_json::Value = serde_json::json!({
23 "etherscan": config.chains.api_keys.contains_key("etherscan") ||
24 std::env::var("ETHERSCAN_API_KEY").is_ok(),
25 "polygonscan": config.chains.api_keys.contains_key("polygonscan"),
26 "bscscan": config.chains.api_keys.contains_key("bscscan"),
27 "solscan": config.chains.api_keys.contains_key("solscan"),
28 "tronscan": config.chains.api_keys.contains_key("tronscan"),
29 });
30
31 let rpc_status = serde_json::json!({
32 "ethereum_rpc": config.chains.ethereum_rpc.is_some(),
33 "bsc_rpc": config.chains.bsc_rpc.is_some(),
34 "solana_rpc": config.chains.solana_rpc.is_some(),
35 "tron_api": config.chains.tron_api.is_some(),
36 });
37
38 Json(serde_json::json!({
39 "config_path": config_path,
40 "config_exists": config_exists,
41 "output_format": format!("{:?}", config.output.format),
42 "color_enabled": config.output.color,
43 "api_keys": api_keys_status,
44 "rpc_endpoints": rpc_status,
45 "version": scope::VERSION,
46 }))
47 .into_response()
48}
49
50#[derive(Debug, Deserialize)]
52pub struct SaveConfigRequest {
53 #[serde(default)]
55 pub api_keys: std::collections::HashMap<String, String>,
56 #[serde(default)]
58 pub rpc_endpoints: std::collections::HashMap<String, String>,
59}
60
61pub async fn handle_save(
63 State(_state): State<Arc<AppState>>,
64 Json(req): Json<SaveConfigRequest>,
65) -> impl IntoResponse {
66 let mut config = Config::load(None).unwrap_or_default();
68
69 for (key, value) in &req.api_keys {
71 if !value.is_empty() {
72 config.chains.api_keys.insert(key.clone(), value.clone());
73 }
74 }
75
76 for (key, value) in &req.rpc_endpoints {
78 if !value.is_empty() {
79 match key.as_str() {
80 "ethereum_rpc" => config.chains.ethereum_rpc = Some(value.clone()),
81 "bsc_rpc" => config.chains.bsc_rpc = Some(value.clone()),
82 "solana_rpc" => config.chains.solana_rpc = Some(value.clone()),
83 "tron_api" => config.chains.tron_api = Some(value.clone()),
84 _ => {}
85 }
86 }
87 }
88
89 let config_path = match Config::config_path() {
91 Some(p) => p,
92 None => {
93 return (
94 StatusCode::INTERNAL_SERVER_ERROR,
95 Json(serde_json::json!({ "error": "Cannot determine config path" })),
96 )
97 .into_response();
98 }
99 };
100
101 if let Some(parent) = config_path.parent()
103 && let Err(e) = std::fs::create_dir_all(parent)
104 {
105 return (
106 StatusCode::INTERNAL_SERVER_ERROR,
107 Json(serde_json::json!({ "error": format!("Failed to create config dir: {}", e) })),
108 )
109 .into_response();
110 }
111
112 match serde_yaml::to_string(&config) {
113 Ok(yaml) => {
114 if let Err(e) = std::fs::write(&config_path, yaml) {
115 return (
116 StatusCode::INTERNAL_SERVER_ERROR,
117 Json(serde_json::json!({ "error": format!("Failed to write config: {}", e) })),
118 )
119 .into_response();
120 }
121 Json(serde_json::json!({
122 "status": "saved",
123 "path": config_path.display().to_string(),
124 }))
125 .into_response()
126 }
127 Err(e) => (
128 StatusCode::INTERNAL_SERVER_ERROR,
129 Json(serde_json::json!({ "error": format!("Failed to serialize config: {}", e) })),
130 )
131 .into_response(),
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 static HOME_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
147
148 #[test]
149 fn test_deserialize_save_config_full() {
150 let json = serde_json::json!({
151 "api_keys": {
152 "etherscan": "test_key_123",
153 "polygonscan": "test_key_456"
154 },
155 "rpc_endpoints": {
156 "ethereum_rpc": "https://eth.example.com",
157 "bsc_rpc": "https://bsc.example.com"
158 }
159 });
160 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
161 assert_eq!(req.api_keys.len(), 2);
162 assert_eq!(
163 req.api_keys.get("etherscan"),
164 Some(&"test_key_123".to_string())
165 );
166 assert_eq!(
167 req.api_keys.get("polygonscan"),
168 Some(&"test_key_456".to_string())
169 );
170 assert_eq!(req.rpc_endpoints.len(), 2);
171 assert_eq!(
172 req.rpc_endpoints.get("ethereum_rpc"),
173 Some(&"https://eth.example.com".to_string())
174 );
175 assert_eq!(
176 req.rpc_endpoints.get("bsc_rpc"),
177 Some(&"https://bsc.example.com".to_string())
178 );
179 }
180
181 #[test]
182 fn test_deserialize_save_config_empty() {
183 let json = serde_json::json!({});
184 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
185 assert_eq!(req.api_keys.len(), 0);
186 assert_eq!(req.rpc_endpoints.len(), 0);
187 }
188
189 #[test]
190 fn test_deserialize_save_config_partial() {
191 let json = serde_json::json!({
192 "api_keys": {
193 "etherscan": "test_key_123"
194 }
195 });
196 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
197 assert_eq!(req.api_keys.len(), 1);
198 assert_eq!(
199 req.api_keys.get("etherscan"),
200 Some(&"test_key_123".to_string())
201 );
202 assert_eq!(req.rpc_endpoints.len(), 0);
203 }
204
205 #[tokio::test]
206 async fn test_handle_config_status() {
207 use crate::AppState;
208 use axum::extract::State;
209 use axum::response::IntoResponse;
210 use scope::chains::DefaultClientFactory;
211 use scope::config::Config;
212
213 let config = Config::default();
214 let http: std::sync::Arc<dyn scope::http::HttpClient> =
215 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
216 let factory = DefaultClientFactory {
217 chains_config: config.chains.clone(),
218 http,
219 };
220 let state = std::sync::Arc::new(AppState { config, factory });
221 let response = handle(State(state)).await.into_response();
222 assert_eq!(response.status(), axum::http::StatusCode::OK);
223
224 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
225 .await
226 .unwrap();
227 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
228 assert!(json.get("config_path").is_some());
229 assert!(json.get("api_keys").is_some());
230 assert!(json.get("rpc_endpoints").is_some());
231 assert!(json.get("version").is_some());
232 }
233
234 #[tokio::test]
235 async fn test_handle_save_config() {
236 use crate::AppState;
237 use axum::extract::State;
238 use axum::response::IntoResponse;
239 use scope::chains::DefaultClientFactory;
240 use scope::config::Config;
241
242 let config = Config::default();
243 let http: std::sync::Arc<dyn scope::http::HttpClient> =
244 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
245 let factory = DefaultClientFactory {
246 chains_config: config.chains.clone(),
247 http,
248 };
249 let state = std::sync::Arc::new(AppState { config, factory });
250 let req = SaveConfigRequest {
251 api_keys: std::collections::HashMap::new(),
252 rpc_endpoints: std::collections::HashMap::new(),
253 };
254 let response = handle_save(State(state), axum::Json(req))
255 .await
256 .into_response();
257 let status = response.status();
259 assert!(
260 status == axum::http::StatusCode::OK
261 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
262 );
263 }
264
265 #[test]
266 fn test_save_config_request_debug() {
267 let req = SaveConfigRequest {
268 api_keys: std::collections::HashMap::new(),
269 rpc_endpoints: std::collections::HashMap::new(),
270 };
271 let debug = format!("{:?}", req);
272 assert!(debug.contains("SaveConfigRequest"));
273 }
274
275 #[tokio::test]
276 async fn test_handle_save_config_path_none() {
277 use crate::AppState;
278 use axum::extract::State;
279 use axum::http::StatusCode;
280 use axum::response::IntoResponse;
281 use scope::chains::DefaultClientFactory;
282 use scope::config::Config;
283
284 let _home_guard = HOME_LOCK.lock().await;
285
286 let config = Config::default();
287 let http: std::sync::Arc<dyn scope::http::HttpClient> =
288 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
289 let factory = DefaultClientFactory {
290 chains_config: config.chains.clone(),
291 http,
292 };
293 let state = std::sync::Arc::new(AppState { config, factory });
294
295 let req = SaveConfigRequest {
296 api_keys: std::collections::HashMap::new(),
297 rpc_endpoints: std::collections::HashMap::new(),
298 };
299
300 let old_home = std::env::var_os("HOME");
301 let old_userprofile = std::env::var_os("USERPROFILE");
302 unsafe {
303 std::env::remove_var("HOME");
304 std::env::remove_var("USERPROFILE");
305 }
306
307 let response = handle_save(State(state), axum::Json(req))
308 .await
309 .into_response();
310
311 if let Some(h) = old_home {
312 unsafe { std::env::set_var("HOME", h) };
313 }
314 if let Some(u) = old_userprofile {
315 unsafe { std::env::set_var("USERPROFILE", u) };
316 }
317
318 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
319 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
320 .await
321 .unwrap();
322 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
323 assert!(
324 json["error"]
325 .as_str()
326 .unwrap()
327 .contains("Cannot determine config path")
328 || json["error"]
329 .as_str()
330 .unwrap()
331 .contains("Failed to create config dir")
332 || json["error"]
333 .as_str()
334 .unwrap()
335 .contains("Failed to write config")
336 );
337 }
338 }
339
340 #[tokio::test]
341 async fn test_handle_save_config_create_dir_fails() {
342 use crate::AppState;
343 use axum::extract::State;
344 use axum::http::StatusCode;
345 use axum::response::IntoResponse;
346 use scope::chains::DefaultClientFactory;
347 use scope::config::Config;
348
349 let _home_guard = HOME_LOCK.lock().await;
350
351 let tmp = tempfile::tempdir().unwrap();
352 let fake_home = tmp.path().join("fake_home");
353 std::fs::create_dir_all(&fake_home).unwrap();
354 let config_as_file = fake_home.join(".config");
355 std::fs::File::create(&config_as_file).unwrap();
356
357 let config = Config::default();
358 let http: std::sync::Arc<dyn scope::http::HttpClient> =
359 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
360 let factory = DefaultClientFactory {
361 chains_config: config.chains.clone(),
362 http,
363 };
364 let state = std::sync::Arc::new(AppState { config, factory });
365
366 let req = SaveConfigRequest {
367 api_keys: std::collections::HashMap::new(),
368 rpc_endpoints: std::collections::HashMap::new(),
369 };
370
371 let old_home = std::env::var_os("HOME");
372 unsafe { std::env::set_var("HOME", &fake_home) };
373
374 let response = handle_save(State(state), axum::Json(req))
375 .await
376 .into_response();
377
378 if let Some(h) = old_home {
379 unsafe { std::env::set_var("HOME", h) };
380 } else {
381 unsafe { std::env::remove_var("HOME") };
382 }
383
384 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
385 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
386 .await
387 .unwrap();
388 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
389 assert!(
390 json["error"]
391 .as_str()
392 .unwrap()
393 .contains("Failed to create config dir")
394 );
395 }
396
397 #[tokio::test]
398 async fn test_handle_save_config_with_api_keys_and_rpc() {
399 use crate::AppState;
400 use axum::extract::State;
401 use axum::response::IntoResponse;
402 use scope::chains::DefaultClientFactory;
403 use scope::config::Config;
404
405 let _home_guard = HOME_LOCK.lock().await;
406
407 let config = Config::default();
408 let http: std::sync::Arc<dyn scope::http::HttpClient> =
409 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
410 let factory = DefaultClientFactory {
411 chains_config: config.chains.clone(),
412 http,
413 };
414 let state = std::sync::Arc::new(AppState { config, factory });
415
416 let mut api_keys = std::collections::HashMap::new();
417 api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
418 api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
419 api_keys.insert("empty_key".to_string(), "".to_string()); let mut rpc_endpoints = std::collections::HashMap::new();
422 rpc_endpoints.insert(
423 "ethereum_rpc".to_string(),
424 "https://eth.example.com".to_string(),
425 );
426 rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
427 rpc_endpoints.insert(
428 "solana_rpc".to_string(),
429 "https://sol.example.com".to_string(),
430 );
431 rpc_endpoints.insert(
432 "tron_api".to_string(),
433 "https://tron.example.com".to_string(),
434 );
435 rpc_endpoints.insert(
436 "unknown_key".to_string(),
437 "https://unknown.example.com".to_string(),
438 );
439 rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); let req = SaveConfigRequest {
442 api_keys,
443 rpc_endpoints,
444 };
445
446 let response = handle_save(State(state), axum::Json(req))
447 .await
448 .into_response();
449 let status = response.status();
450 assert!(
452 status == axum::http::StatusCode::OK
453 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
454 );
455 }
456}