1use async_trait::async_trait;
10use serde::{Deserialize, Serialize};
11use std::{collections::HashMap, path::PathBuf, sync::Arc};
12use tokio::sync::RwLock;
13
14pub mod handlers;
15pub mod server;
16pub mod types;
17
18pub use server::WebService;
19pub use types::*;
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WebServiceConfig {
24 pub port: u16,
26 pub host: String,
28 pub repo_path: PathBuf,
30 pub token_budget: usize,
32 pub auto_open_browser: bool,
34 pub max_file_size: usize,
36 pub auto_exclude_tests: bool,
38 pub auto_shutdown: bool,
40 pub auto_shutdown_timeout: u64,
42}
43
44impl Default for WebServiceConfig {
45 fn default() -> Self {
46 Self {
47 port: 8080,
48 host: "127.0.0.1".to_string(),
49 repo_path: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
50 token_budget: 50000,
51 auto_open_browser: true,
52 max_file_size: 1024 * 1024, auto_exclude_tests: true,
54 auto_shutdown: true,
55 auto_shutdown_timeout: 60,
56 }
57 }
58}
59
60#[derive(Clone)]
62pub struct AppState {
63 pub config: Arc<RwLock<WebServiceConfig>>,
64 pub bundle_state: Arc<RwLock<BundleState>>,
65 pub last_ping: Arc<tokio::sync::RwLock<tokio::time::Instant>>,
66 pub shutdown_sender: Arc<tokio::sync::RwLock<Option<tokio::sync::oneshot::Sender<()>>>>,
67 pub analysis_provider: Arc<dyn AnalysisProvider>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct BundleState {
73 pub included_files: Vec<String>,
74 pub excluded_files: HashMap<String, Vec<String>>, pub token_estimate: usize,
76 pub total_size: usize,
77 pub last_updated: chrono::DateTime<chrono::Utc>,
78}
79
80impl Default for BundleState {
81 fn default() -> Self {
82 Self {
83 included_files: Vec::new(),
84 excluded_files: HashMap::new(),
85 token_estimate: 0,
86 total_size: 0,
87 last_updated: chrono::Utc::now(),
88 }
89 }
90}
91
92#[async_trait]
93pub trait AnalysisProvider: Send + Sync {
94 async fn analyze(&self, config: &WebServiceConfig) -> Result<AnalysisOutput>;
95}
96
97#[derive(Debug, Serialize, Deserialize)]
99pub struct ApiResponse<T> {
100 pub success: bool,
101 pub data: Option<T>,
102 pub error: Option<String>,
103 pub timestamp: chrono::DateTime<chrono::Utc>,
104}
105
106impl<T> ApiResponse<T> {
107 pub fn success(data: T) -> Self {
108 Self {
109 success: true,
110 data: Some(data),
111 error: None,
112 timestamp: chrono::Utc::now(),
113 }
114 }
115
116 pub fn error(message: String) -> Self {
117 Self {
118 success: false,
119 data: None,
120 error: Some(message),
121 timestamp: chrono::Utc::now(),
122 }
123 }
124}
125
126#[derive(Debug, thiserror::Error)]
128pub enum WebServiceError {
129 #[error("IO error: {0}")]
130 Io(#[from] std::io::Error),
131
132 #[error("Serialization error: {0}")]
133 Serialization(#[from] serde_json::Error),
134
135 #[error("Scribe core error: {0}")]
136 ScribeCore(String),
137
138 #[error("HTTP error: {0}")]
139 Http(#[from] axum::http::Error),
140
141 #[error("Repository not found: {path}")]
142 RepositoryNotFound { path: PathBuf },
143
144 #[error("File not found: {path}")]
145 FileNotFound { path: String },
146
147 #[error("Invalid request: {message}")]
148 InvalidRequest { message: String },
149}
150
151pub type Result<T> = std::result::Result<T, WebServiceError>;
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use async_trait::async_trait;
157 use std::collections::HashMap;
158 use std::sync::Arc;
159 use tempfile::TempDir;
160 use tokio::sync::RwLock;
161
162 struct DummyProvider;
163
164 #[async_trait]
165 impl AnalysisProvider for DummyProvider {
166 async fn analyze(&self, config: &WebServiceConfig) -> Result<AnalysisOutput> {
167 Ok(AnalysisOutput {
168 selected_files: Vec::new(),
169 selected_file_infos: Vec::new(),
170 metrics: WebSelectionMetrics {
171 total_files_discovered: 0,
172 files_selected: 0,
173 total_tokens_estimated: 0,
174 selection_time_ms: 0,
175 algorithm_used: "test".to_string(),
176 coverage_score: 0.0,
177 relevance_score: 0.0,
178 },
179 repository_files: Vec::new(),
180 token_budget: config.token_budget,
181 })
182 }
183 }
184
185 #[test]
186 fn test_webservice_config_default() {
187 let config = WebServiceConfig::default();
188
189 assert_eq!(config.port, 8080);
190 assert_eq!(config.host, "127.0.0.1");
191 assert_eq!(config.token_budget, 50000);
192 assert!(config.auto_open_browser);
193 assert_eq!(config.max_file_size, 1024 * 1024);
194 assert!(config.auto_exclude_tests);
195 assert!(config.repo_path.ends_with(".") || config.repo_path.is_absolute());
196 }
197
198 #[test]
199 fn test_webservice_config_serialization() {
200 let temp_dir = TempDir::new().unwrap();
201 let config = WebServiceConfig {
202 port: 3000,
203 host: "0.0.0.0".to_string(),
204 repo_path: temp_dir.path().to_path_buf(),
205 token_budget: 25000,
206 auto_open_browser: false,
207 max_file_size: 512 * 1024,
208 auto_exclude_tests: false,
209 auto_shutdown: true,
210 auto_shutdown_timeout: 30,
211 };
212
213 let json = serde_json::to_string(&config).unwrap();
214 let deserialized: WebServiceConfig = serde_json::from_str(&json).unwrap();
215
216 assert_eq!(deserialized.port, 3000);
217 assert_eq!(deserialized.host, "0.0.0.0");
218 assert_eq!(deserialized.token_budget, 25000);
219 assert!(!deserialized.auto_open_browser);
220 assert_eq!(deserialized.max_file_size, 512 * 1024);
221 assert!(!deserialized.auto_exclude_tests);
222 }
223
224 #[test]
225 fn test_bundle_state_default() {
226 let state = BundleState::default();
227
228 assert_eq!(state.included_files.len(), 0);
229 assert_eq!(state.excluded_files.len(), 0);
230 assert_eq!(state.token_estimate, 0);
231 assert_eq!(state.total_size, 0);
232
233 let now = chrono::Utc::now();
235 let duration = now.signed_duration_since(state.last_updated);
236 assert!(duration.num_seconds() < 5);
237 }
238
239 #[test]
240 fn test_bundle_state_serialization() {
241 let mut excluded_files = HashMap::new();
242 excluded_files.insert(
243 "test".to_string(),
244 vec!["test1.rs".to_string(), "test2.rs".to_string()],
245 );
246
247 let state = BundleState {
248 included_files: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
249 excluded_files,
250 token_estimate: 5000,
251 total_size: 10240,
252 last_updated: chrono::Utc::now(),
253 };
254
255 let json = serde_json::to_string(&state).unwrap();
256 let deserialized: BundleState = serde_json::from_str(&json).unwrap();
257
258 assert_eq!(deserialized.included_files.len(), 2);
259 assert!(deserialized
260 .included_files
261 .contains(&"src/lib.rs".to_string()));
262 assert!(deserialized
263 .included_files
264 .contains(&"src/main.rs".to_string()));
265 assert_eq!(deserialized.token_estimate, 5000);
266 assert_eq!(deserialized.total_size, 10240);
267 assert!(deserialized.excluded_files.contains_key("test"));
268 }
269
270 #[test]
271 fn test_app_state_structure() {
272 let temp_dir = TempDir::new().unwrap();
273 let config = WebServiceConfig {
274 repo_path: temp_dir.path().to_path_buf(),
275 port: 8080,
276 ..Default::default()
277 };
278
279 let config_arc = Arc::new(RwLock::new(config.clone()));
280 let state = AppState {
281 config: config_arc.clone(),
282 bundle_state: Arc::new(RwLock::new(BundleState::default())),
283 last_ping: Arc::new(tokio::sync::RwLock::new(tokio::time::Instant::now())),
284 shutdown_sender: Arc::new(tokio::sync::RwLock::new(None)),
285 analysis_provider: Arc::new(DummyProvider),
286 };
287
288 let cfg_guard = state.config.blocking_read();
289 assert_eq!(cfg_guard.port, config.port);
290 assert_eq!(cfg_guard.host, config.host);
291
292 let bundle_state = state.bundle_state.try_read().unwrap();
294 assert_eq!(bundle_state.included_files.len(), 0);
295 }
296
297 #[test]
298 fn test_api_response_success() {
299 let data = vec!["item1", "item2", "item3"];
300 let response = ApiResponse::success(data.clone());
301
302 assert!(response.success);
303 assert_eq!(response.data, Some(data));
304 assert!(response.error.is_none());
305
306 let now = chrono::Utc::now();
308 let duration = now.signed_duration_since(response.timestamp);
309 assert!(duration.num_seconds() < 5);
310 }
311
312 #[test]
313 fn test_api_response_error() {
314 let error_msg = "Something went wrong".to_string();
315 let response = ApiResponse::<String>::error(error_msg.clone());
316
317 assert!(!response.success);
318 assert!(response.data.is_none());
319 assert_eq!(response.error, Some(error_msg));
320
321 let now = chrono::Utc::now();
323 let duration = now.signed_duration_since(response.timestamp);
324 assert!(duration.num_seconds() < 5);
325 }
326
327 #[test]
328 fn test_api_response_serialization() {
329 let success_response = ApiResponse::success("test data".to_string());
330 let json = serde_json::to_string(&success_response).unwrap();
331 let deserialized: ApiResponse<String> = serde_json::from_str(&json).unwrap();
332
333 assert!(deserialized.success);
334 assert_eq!(deserialized.data, Some("test data".to_string()));
335 assert!(deserialized.error.is_none());
336
337 let error_response = ApiResponse::<String>::error("test error".to_string());
338 let json = serde_json::to_string(&error_response).unwrap();
339 let deserialized: ApiResponse<String> = serde_json::from_str(&json).unwrap();
340
341 assert!(!deserialized.success);
342 assert!(deserialized.data.is_none());
343 assert_eq!(deserialized.error, Some("test error".to_string()));
344 }
345
346 #[test]
347 fn test_webservice_error_display() {
348 let errors = vec![
349 WebServiceError::Io(std::io::Error::new(
350 std::io::ErrorKind::NotFound,
351 "File not found",
352 )),
353 WebServiceError::ScribeCore("Core error".to_string()),
354 WebServiceError::RepositoryNotFound {
355 path: PathBuf::from("/test/path"),
356 },
357 WebServiceError::FileNotFound {
358 path: "test.rs".to_string(),
359 },
360 WebServiceError::InvalidRequest {
361 message: "Bad request".to_string(),
362 },
363 ];
364
365 for error in errors {
366 let error_string = error.to_string();
367 assert!(!error_string.is_empty());
368 }
369 }
370
371 #[test]
372 fn test_webservice_error_from_io() {
373 let io_error =
374 std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Permission denied");
375 let webservice_error: WebServiceError = io_error.into();
376
377 match webservice_error {
378 WebServiceError::Io(_) => (),
379 _ => panic!("Expected IO error"),
380 }
381 }
382
383 #[test]
384 fn test_webservice_error_from_serde() {
385 let json = r#"{"invalid": json}"#;
386 let serde_error = serde_json::from_str::<serde_json::Value>(json).unwrap_err();
387 let webservice_error: WebServiceError = serde_error.into();
388
389 match webservice_error {
390 WebServiceError::Serialization(_) => (),
391 _ => panic!("Expected Serialization error"),
392 }
393 }
394
395 #[test]
396 fn test_result_type_alias() {
397 fn test_function() -> Result<String> {
398 Ok("success".to_string())
399 }
400
401 let result = test_function();
402 assert!(result.is_ok());
403 assert_eq!(result.unwrap(), "success");
404
405 fn error_function() -> Result<String> {
406 Err(WebServiceError::InvalidRequest {
407 message: "test".to_string(),
408 })
409 }
410
411 let result = error_function();
412 assert!(result.is_err());
413 }
414
415 #[test]
416 fn test_webservice_config_edge_cases() {
417 let config = WebServiceConfig {
419 port: 1,
420 host: "".to_string(),
421 repo_path: PathBuf::from("/"),
422 token_budget: 1,
423 auto_open_browser: false,
424 max_file_size: 1,
425 auto_exclude_tests: false,
426 auto_shutdown: true,
427 auto_shutdown_timeout: 30,
428 };
429
430 assert_eq!(config.port, 1);
431 assert_eq!(config.host, "");
432 assert_eq!(config.token_budget, 1);
433 assert_eq!(config.max_file_size, 1);
434
435 let config = WebServiceConfig {
437 port: 65535,
438 host: "very.long.hostname.example.com".to_string(),
439 repo_path: PathBuf::from("/very/long/path/to/repository/with/many/nested/directories"),
440 token_budget: 1_000_000,
441 auto_open_browser: true,
442 max_file_size: 100 * 1024 * 1024, auto_exclude_tests: true,
444 auto_shutdown: false,
445 auto_shutdown_timeout: 300,
446 };
447
448 assert_eq!(config.port, 65535);
449 assert_eq!(config.host, "very.long.hostname.example.com");
450 assert_eq!(config.token_budget, 1_000_000);
451 assert_eq!(config.max_file_size, 100 * 1024 * 1024);
452 }
453}