1use crate::config::Config;
4use crate::web::AppState;
5use axum::Json;
6use axum::extract::State;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
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": crate::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 #[test]
140 fn test_deserialize_save_config_full() {
141 let json = serde_json::json!({
142 "api_keys": {
143 "etherscan": "test_key_123",
144 "polygonscan": "test_key_456"
145 },
146 "rpc_endpoints": {
147 "ethereum_rpc": "https://eth.example.com",
148 "bsc_rpc": "https://bsc.example.com"
149 }
150 });
151 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
152 assert_eq!(req.api_keys.len(), 2);
153 assert_eq!(
154 req.api_keys.get("etherscan"),
155 Some(&"test_key_123".to_string())
156 );
157 assert_eq!(
158 req.api_keys.get("polygonscan"),
159 Some(&"test_key_456".to_string())
160 );
161 assert_eq!(req.rpc_endpoints.len(), 2);
162 assert_eq!(
163 req.rpc_endpoints.get("ethereum_rpc"),
164 Some(&"https://eth.example.com".to_string())
165 );
166 assert_eq!(
167 req.rpc_endpoints.get("bsc_rpc"),
168 Some(&"https://bsc.example.com".to_string())
169 );
170 }
171
172 #[test]
173 fn test_deserialize_save_config_empty() {
174 let json = serde_json::json!({});
175 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
176 assert_eq!(req.api_keys.len(), 0);
177 assert_eq!(req.rpc_endpoints.len(), 0);
178 }
179
180 #[test]
181 fn test_deserialize_save_config_partial() {
182 let json = serde_json::json!({
183 "api_keys": {
184 "etherscan": "test_key_123"
185 }
186 });
187 let req: SaveConfigRequest = serde_json::from_value(json).unwrap();
188 assert_eq!(req.api_keys.len(), 1);
189 assert_eq!(
190 req.api_keys.get("etherscan"),
191 Some(&"test_key_123".to_string())
192 );
193 assert_eq!(req.rpc_endpoints.len(), 0);
194 }
195
196 #[tokio::test]
197 async fn test_handle_config_status() {
198 use crate::chains::DefaultClientFactory;
199 use crate::config::Config;
200 use crate::web::AppState;
201 use axum::extract::State;
202 use axum::response::IntoResponse;
203
204 let config = Config::default();
205 let http: std::sync::Arc<dyn crate::http::HttpClient> =
206 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
207 let factory = DefaultClientFactory {
208 chains_config: config.chains.clone(),
209 http,
210 };
211 let state = std::sync::Arc::new(AppState { config, factory });
212 let response = handle(State(state)).await.into_response();
213 assert_eq!(response.status(), axum::http::StatusCode::OK);
214
215 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
216 .await
217 .unwrap();
218 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
219 assert!(json.get("config_path").is_some());
220 assert!(json.get("api_keys").is_some());
221 assert!(json.get("rpc_endpoints").is_some());
222 assert!(json.get("version").is_some());
223 }
224
225 #[tokio::test]
226 async fn test_handle_save_config() {
227 use crate::chains::DefaultClientFactory;
228 use crate::config::Config;
229 use crate::web::AppState;
230 use axum::extract::State;
231 use axum::response::IntoResponse;
232
233 let config = Config::default();
234 let http: std::sync::Arc<dyn crate::http::HttpClient> =
235 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
236 let factory = DefaultClientFactory {
237 chains_config: config.chains.clone(),
238 http,
239 };
240 let state = std::sync::Arc::new(AppState { config, factory });
241 let req = SaveConfigRequest {
242 api_keys: std::collections::HashMap::new(),
243 rpc_endpoints: std::collections::HashMap::new(),
244 };
245 let response = handle_save(State(state), axum::Json(req))
246 .await
247 .into_response();
248 let status = response.status();
250 assert!(
251 status == axum::http::StatusCode::OK
252 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
253 );
254 }
255
256 #[test]
257 fn test_save_config_request_debug() {
258 let req = SaveConfigRequest {
259 api_keys: std::collections::HashMap::new(),
260 rpc_endpoints: std::collections::HashMap::new(),
261 };
262 let debug = format!("{:?}", req);
263 assert!(debug.contains("SaveConfigRequest"));
264 }
265
266 #[tokio::test]
267 async fn test_handle_save_config_path_none() {
268 use crate::chains::DefaultClientFactory;
269 use crate::config::Config;
270 use crate::web::AppState;
271 use axum::extract::State;
272 use axum::http::StatusCode;
273 use axum::response::IntoResponse;
274
275 let config = Config::default();
276 let http: std::sync::Arc<dyn crate::http::HttpClient> =
277 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
278 let factory = DefaultClientFactory {
279 chains_config: config.chains.clone(),
280 http,
281 };
282 let state = std::sync::Arc::new(AppState { config, factory });
283
284 let req = SaveConfigRequest {
285 api_keys: std::collections::HashMap::new(),
286 rpc_endpoints: std::collections::HashMap::new(),
287 };
288
289 let old_home = std::env::var_os("HOME");
290 let old_userprofile = std::env::var_os("USERPROFILE");
291 unsafe {
292 std::env::remove_var("HOME");
293 std::env::remove_var("USERPROFILE");
294 }
295
296 let response = handle_save(State(state), axum::Json(req))
297 .await
298 .into_response();
299
300 if let Some(h) = old_home {
301 unsafe { std::env::set_var("HOME", h) };
302 }
303 if let Some(u) = old_userprofile {
304 unsafe { std::env::set_var("USERPROFILE", u) };
305 }
306
307 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
308 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
309 .await
310 .unwrap();
311 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
312 assert!(
313 json["error"]
314 .as_str()
315 .unwrap()
316 .contains("Cannot determine config path")
317 || json["error"]
318 .as_str()
319 .unwrap()
320 .contains("Failed to create config dir")
321 || json["error"]
322 .as_str()
323 .unwrap()
324 .contains("Failed to write config")
325 );
326 }
327 }
328
329 #[tokio::test]
330 async fn test_handle_save_config_create_dir_fails() {
331 use crate::chains::DefaultClientFactory;
332 use crate::config::Config;
333 use crate::web::AppState;
334 use axum::extract::State;
335 use axum::http::StatusCode;
336 use axum::response::IntoResponse;
337
338 let tmp = tempfile::tempdir().unwrap();
339 let fake_home = tmp.path().join("fake_home");
340 std::fs::create_dir_all(&fake_home).unwrap();
341 let config_as_file = fake_home.join(".config");
342 std::fs::File::create(&config_as_file).unwrap();
343
344 let config = Config::default();
345 let http: std::sync::Arc<dyn crate::http::HttpClient> =
346 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
347 let factory = DefaultClientFactory {
348 chains_config: config.chains.clone(),
349 http,
350 };
351 let state = std::sync::Arc::new(AppState { config, factory });
352
353 let req = SaveConfigRequest {
354 api_keys: std::collections::HashMap::new(),
355 rpc_endpoints: std::collections::HashMap::new(),
356 };
357
358 let old_home = std::env::var_os("HOME");
359 unsafe { std::env::set_var("HOME", &fake_home) };
360
361 let response = handle_save(State(state), axum::Json(req))
362 .await
363 .into_response();
364
365 if let Some(h) = old_home {
366 unsafe { std::env::set_var("HOME", h) };
367 } else {
368 unsafe { std::env::remove_var("HOME") };
369 }
370
371 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
372 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
373 .await
374 .unwrap();
375 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
376 assert!(
377 json["error"]
378 .as_str()
379 .unwrap()
380 .contains("Failed to create config dir")
381 );
382 }
383
384 #[tokio::test]
385 async fn test_handle_save_config_with_api_keys_and_rpc() {
386 use crate::chains::DefaultClientFactory;
387 use crate::config::Config;
388 use crate::web::AppState;
389 use axum::extract::State;
390 use axum::response::IntoResponse;
391
392 let config = Config::default();
393 let http: std::sync::Arc<dyn crate::http::HttpClient> =
394 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
395 let factory = DefaultClientFactory {
396 chains_config: config.chains.clone(),
397 http,
398 };
399 let state = std::sync::Arc::new(AppState { config, factory });
400
401 let mut api_keys = std::collections::HashMap::new();
402 api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
403 api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
404 api_keys.insert("empty_key".to_string(), "".to_string()); let mut rpc_endpoints = std::collections::HashMap::new();
407 rpc_endpoints.insert(
408 "ethereum_rpc".to_string(),
409 "https://eth.example.com".to_string(),
410 );
411 rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
412 rpc_endpoints.insert(
413 "solana_rpc".to_string(),
414 "https://sol.example.com".to_string(),
415 );
416 rpc_endpoints.insert(
417 "tron_api".to_string(),
418 "https://tron.example.com".to_string(),
419 );
420 rpc_endpoints.insert(
421 "unknown_key".to_string(),
422 "https://unknown.example.com".to_string(),
423 );
424 rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); let req = SaveConfigRequest {
427 api_keys,
428 rpc_endpoints,
429 };
430
431 let response = handle_save(State(state), axum::Json(req))
432 .await
433 .into_response();
434 let status = response.status();
435 assert!(
437 status == axum::http::StatusCode::OK
438 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
439 );
440 }
441}