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