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 factory = DefaultClientFactory {
206 chains_config: config.chains.clone(),
207 };
208 let state = std::sync::Arc::new(AppState { config, factory });
209 let response = handle(State(state)).await.into_response();
210 assert_eq!(response.status(), axum::http::StatusCode::OK);
211
212 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
213 .await
214 .unwrap();
215 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
216 assert!(json.get("config_path").is_some());
217 assert!(json.get("api_keys").is_some());
218 assert!(json.get("rpc_endpoints").is_some());
219 assert!(json.get("version").is_some());
220 }
221
222 #[tokio::test]
223 async fn test_handle_save_config() {
224 use crate::chains::DefaultClientFactory;
225 use crate::config::Config;
226 use crate::web::AppState;
227 use axum::extract::State;
228 use axum::response::IntoResponse;
229
230 let config = Config::default();
231 let factory = DefaultClientFactory {
232 chains_config: config.chains.clone(),
233 };
234 let state = std::sync::Arc::new(AppState { config, factory });
235 let req = SaveConfigRequest {
236 api_keys: std::collections::HashMap::new(),
237 rpc_endpoints: std::collections::HashMap::new(),
238 };
239 let response = handle_save(State(state), axum::Json(req))
240 .await
241 .into_response();
242 let status = response.status();
244 assert!(
245 status == axum::http::StatusCode::OK
246 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
247 );
248 }
249
250 #[test]
251 fn test_save_config_request_debug() {
252 let req = SaveConfigRequest {
253 api_keys: std::collections::HashMap::new(),
254 rpc_endpoints: std::collections::HashMap::new(),
255 };
256 let debug = format!("{:?}", req);
257 assert!(debug.contains("SaveConfigRequest"));
258 }
259
260 #[tokio::test]
261 async fn test_handle_save_config_path_none() {
262 use crate::chains::DefaultClientFactory;
263 use crate::config::Config;
264 use crate::web::AppState;
265 use axum::extract::State;
266 use axum::http::StatusCode;
267 use axum::response::IntoResponse;
268
269 let config = Config::default();
270 let factory = DefaultClientFactory {
271 chains_config: config.chains.clone(),
272 };
273 let state = std::sync::Arc::new(AppState { config, factory });
274
275 let req = SaveConfigRequest {
276 api_keys: std::collections::HashMap::new(),
277 rpc_endpoints: std::collections::HashMap::new(),
278 };
279
280 let old_home = std::env::var_os("HOME");
281 let old_userprofile = std::env::var_os("USERPROFILE");
282 unsafe {
283 std::env::remove_var("HOME");
284 std::env::remove_var("USERPROFILE");
285 }
286
287 let response = handle_save(State(state), axum::Json(req))
288 .await
289 .into_response();
290
291 if let Some(h) = old_home {
292 unsafe { std::env::set_var("HOME", h) };
293 }
294 if let Some(u) = old_userprofile {
295 unsafe { std::env::set_var("USERPROFILE", u) };
296 }
297
298 if response.status() == StatusCode::INTERNAL_SERVER_ERROR {
299 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
300 .await
301 .unwrap();
302 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
303 assert!(
304 json["error"]
305 .as_str()
306 .unwrap()
307 .contains("Cannot determine config path")
308 || json["error"]
309 .as_str()
310 .unwrap()
311 .contains("Failed to create config dir")
312 || json["error"]
313 .as_str()
314 .unwrap()
315 .contains("Failed to write config")
316 );
317 }
318 }
319
320 #[tokio::test]
321 async fn test_handle_save_config_create_dir_fails() {
322 use crate::chains::DefaultClientFactory;
323 use crate::config::Config;
324 use crate::web::AppState;
325 use axum::extract::State;
326 use axum::http::StatusCode;
327 use axum::response::IntoResponse;
328
329 let tmp = tempfile::tempdir().unwrap();
330 let fake_home = tmp.path().join("fake_home");
331 std::fs::create_dir_all(&fake_home).unwrap();
332 let config_as_file = fake_home.join(".config");
333 std::fs::File::create(&config_as_file).unwrap();
334
335 let config = Config::default();
336 let factory = DefaultClientFactory {
337 chains_config: config.chains.clone(),
338 };
339 let state = std::sync::Arc::new(AppState { config, factory });
340
341 let req = SaveConfigRequest {
342 api_keys: std::collections::HashMap::new(),
343 rpc_endpoints: std::collections::HashMap::new(),
344 };
345
346 let old_home = std::env::var_os("HOME");
347 unsafe { std::env::set_var("HOME", &fake_home) };
348
349 let response = handle_save(State(state), axum::Json(req))
350 .await
351 .into_response();
352
353 if let Some(h) = old_home {
354 unsafe { std::env::set_var("HOME", h) };
355 } else {
356 unsafe { std::env::remove_var("HOME") };
357 }
358
359 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
360 let body = axum::body::to_bytes(response.into_body(), 1_000_000)
361 .await
362 .unwrap();
363 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
364 assert!(
365 json["error"]
366 .as_str()
367 .unwrap()
368 .contains("Failed to create config dir")
369 );
370 }
371
372 #[tokio::test]
373 async fn test_handle_save_config_with_api_keys_and_rpc() {
374 use crate::chains::DefaultClientFactory;
375 use crate::config::Config;
376 use crate::web::AppState;
377 use axum::extract::State;
378 use axum::response::IntoResponse;
379
380 let config = Config::default();
381 let factory = DefaultClientFactory {
382 chains_config: config.chains.clone(),
383 };
384 let state = std::sync::Arc::new(AppState { config, factory });
385
386 let mut api_keys = std::collections::HashMap::new();
387 api_keys.insert("etherscan".to_string(), "test_key_abc".to_string());
388 api_keys.insert("polygonscan".to_string(), "test_key_def".to_string());
389 api_keys.insert("empty_key".to_string(), "".to_string()); let mut rpc_endpoints = std::collections::HashMap::new();
392 rpc_endpoints.insert(
393 "ethereum_rpc".to_string(),
394 "https://eth.example.com".to_string(),
395 );
396 rpc_endpoints.insert("bsc_rpc".to_string(), "https://bsc.example.com".to_string());
397 rpc_endpoints.insert(
398 "solana_rpc".to_string(),
399 "https://sol.example.com".to_string(),
400 );
401 rpc_endpoints.insert(
402 "tron_api".to_string(),
403 "https://tron.example.com".to_string(),
404 );
405 rpc_endpoints.insert(
406 "unknown_key".to_string(),
407 "https://unknown.example.com".to_string(),
408 );
409 rpc_endpoints.insert("empty_rpc".to_string(), "".to_string()); let req = SaveConfigRequest {
412 api_keys,
413 rpc_endpoints,
414 };
415
416 let response = handle_save(State(state), axum::Json(req))
417 .await
418 .into_response();
419 let status = response.status();
420 assert!(
422 status == axum::http::StatusCode::OK
423 || status == axum::http::StatusCode::INTERNAL_SERVER_ERROR
424 );
425 }
426}