scribe_webservice/
lib.rs

1//! Web service interface for Scribe repository analysis
2//!
3//! This crate provides a web-based interface for Scribe that includes:
4//! - HTTP server with REST API endpoints
5//! - Real-time bundle generation and saving
6//! - Automatic browser opening
7//! - Interactive file selection interface
8
9use 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/// Configuration for the web service
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct WebServiceConfig {
24    /// Port to bind to
25    pub port: u16,
26    /// Host to bind to  
27    pub host: String,
28    /// Repository path to analyze
29    pub repo_path: PathBuf,
30    /// Token budget for selection
31    pub token_budget: usize,
32    /// Whether to auto-open browser
33    pub auto_open_browser: bool,
34    /// Maximum file size to consider
35    pub max_file_size: usize,
36    /// Whether to exclude tests automatically
37    pub auto_exclude_tests: bool,
38    /// Whether to auto-shutdown after inactivity (default true)
39    pub auto_shutdown: bool,
40    /// Auto-shutdown timeout in seconds (default 60)
41    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, // 1MB
53            auto_exclude_tests: true,
54            auto_shutdown: true,
55            auto_shutdown_timeout: 60,
56        }
57    }
58}
59
60/// Application state shared across handlers
61#[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/// Current bundle state
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct BundleState {
73    pub included_files: Vec<String>,
74    pub excluded_files: HashMap<String, Vec<String>>, // category -> files
75    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/// API response wrapper
98#[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/// Error types for the web service
127#[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        // Check that last_updated is recent
234        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        // Test that we can access the bundle state
293        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        // Check timestamp is recent
307        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        // Check timestamp is recent
322        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        // Test with minimal values
418        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        // Test with maximum reasonable values
436        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, // 100MB
443            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}