1use anyhow::Result;
12use include_dir::{Dir, include_dir};
13use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::Duration;
17use tokio::sync::RwLock;
18use tokio::time::sleep;
19use warp::{Filter, Reply};
20
21use crate::file_scanner::list_files_recursive_with_changes;
22use crate::git_helper::get_repository_url;
23use crate::models::Feature;
24
25static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/public");
27
28#[derive(Debug, Clone)]
30pub struct ServerConfig {
31 pub port: u16,
33 pub host: [u8; 4],
35}
36
37impl Default for ServerConfig {
38 fn default() -> Self {
39 Self {
40 port: 3000,
41 host: [127, 0, 0, 1],
42 }
43 }
44}
45
46impl ServerConfig {
47 pub fn new(port: u16) -> Self {
49 Self {
50 port,
51 ..Default::default()
52 }
53 }
54
55 #[allow(dead_code)]
57 pub fn with_host(mut self, host: [u8; 4]) -> Self {
58 self.host = host;
59 self
60 }
61}
62
63pub async fn serve_features_with_watching(
77 features: &[Feature],
78 port: u16,
79 watch_path: PathBuf,
80 on_ready: Option<Box<dyn FnOnce() + Send>>,
81 skip_changes: bool,
82) -> Result<()> {
83 let config = ServerConfig::new(port);
84 serve_features_with_config_and_watching(
85 features,
86 config,
87 Some(watch_path.clone()),
88 on_ready,
89 skip_changes,
90 )
91 .await
92}
93
94pub async fn serve_features_with_config_and_watching(
108 features: &[Feature],
109 config: ServerConfig,
110 watch_path: Option<PathBuf>,
111 on_ready: Option<Box<dyn FnOnce() + Send>>,
112 skip_changes: bool,
113) -> Result<()> {
114 let features_data = Arc::new(RwLock::new(features.to_vec()));
116
117 let repository_url = watch_path
119 .as_ref()
120 .and_then(|path| get_repository_url(path));
121
122 if let Some(ref path) = watch_path {
124 let features_data_clone = Arc::clone(&features_data);
125 let watch_path_clone = path.clone();
126
127 tokio::spawn(async move {
128 if let Err(e) = setup_file_watcher(features_data_clone, watch_path_clone).await {
129 eprintln!("File watcher error: {}", e);
130 }
131 });
132 }
133
134 let features_data_clone = Arc::clone(&features_data);
136 let features_route = warp::path("features.json")
137 .and(warp::get())
138 .and_then(move || {
139 let features_data = Arc::clone(&features_data_clone);
140 async move {
141 let features = features_data.read().await;
142 let features_json = match serde_json::to_string_pretty(&*features) {
143 Ok(json) => json,
144 Err(e) => {
145 eprintln!("Failed to serialize features: {}", e);
146 return Err(warp::reject::custom(SerializationError));
147 }
148 };
149
150 Ok::<_, warp::Rejection>(warp::reply::with_header(
151 features_json,
152 "content-type",
153 "application/json",
154 ))
155 }
156 });
157
158 let metadata_route = warp::path("metadata.json")
160 .and(warp::get())
161 .and_then(move || {
162 let repo_url = repository_url.clone();
163 async move {
164 let mut metadata = serde_json::json!({
165 "version": env!("CARGO_PKG_VERSION"),
166 "skipChanges": skip_changes
167 });
168
169 if let Some(url) = repo_url {
171 metadata["repository"] = serde_json::json!(url);
172 }
173
174 let metadata_json = match serde_json::to_string_pretty(&metadata) {
175 Ok(json) => json,
176 Err(e) => {
177 eprintln!("Failed to serialize metadata: {}", e);
178 return Err(warp::reject::custom(SerializationError));
179 }
180 };
181
182 Ok::<_, warp::Rejection>(warp::reply::with_header(
183 metadata_json,
184 "content-type",
185 "application/json",
186 ))
187 }
188 });
189
190 let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
192
193 let static_route = warp::path::tail()
195 .and(warp::get())
196 .and_then(serve_static_file);
197
198 let routes = features_route
199 .or(metadata_route)
200 .or(index_route)
201 .or(static_route)
202 .with(warp::cors().allow_any_origin())
203 .recover(handle_rejection);
204
205 if let Some(callback) = on_ready {
207 callback();
208 }
209
210 println!(
211 "Server running at http://{}:{}",
212 config
213 .host
214 .iter()
215 .map(|&b| b.to_string())
216 .collect::<Vec<_>>()
217 .join("."),
218 config.port,
219 );
220 warp::serve(routes).run((config.host, config.port)).await;
221
222 Ok(())
223}
224
225async fn setup_file_watcher(
227 features_data: Arc<RwLock<Vec<Feature>>>,
228 watch_path: PathBuf,
229) -> Result<()> {
230 let (tx, mut rx) = tokio::sync::mpsc::channel(100);
231
232 let watch_path_clone = watch_path.clone();
234 let _watcher = tokio::task::spawn_blocking(move || -> Result<RecommendedWatcher> {
235 let mut watcher = RecommendedWatcher::new(
236 move |res: notify::Result<Event>| {
237 match res {
238 Ok(event) => {
239 if let Err(e) = tx.blocking_send(event) {
241 eprintln!("Failed to send file system event: {}", e);
242 }
243 }
244 Err(e) => eprintln!("File watcher error: {:?}", e),
245 }
246 },
247 Config::default(),
248 )?;
249
250 watcher.watch(&watch_path_clone, RecursiveMode::Recursive)?;
251 Ok(watcher)
252 })
253 .await??;
254
255 while let Some(event) = rx.recv().await {
257 let should_recompute = event.paths.iter().any(|path| {
259 path.file_name()
260 .map(|name| name == "README.md")
261 .unwrap_or(false)
262 || event.kind.is_create()
263 || event.kind.is_remove()
264 });
265
266 if should_recompute {
267 sleep(Duration::from_millis(500)).await;
269
270 match list_files_recursive_with_changes(&watch_path) {
271 Ok(new_features) => {
272 let mut features = features_data.write().await;
273 *features = new_features;
274 println!("â
Features updated successfully");
275 }
276 Err(e) => {
277 eprintln!("â Failed to recompute features: {}", e);
278 }
279 }
280 }
281 }
282
283 Ok(())
284}
285
286#[derive(Debug)]
288struct SerializationError;
289impl warp::reject::Reject for SerializationError {}
290
291async fn serve_index() -> Result<impl warp::Reply, warp::Rejection> {
300 if let Some(file) = STATIC_DIR.get_file("index.html") {
301 let content = file.contents_utf8().unwrap_or("");
302 Ok(
303 warp::reply::with_header(content, "content-type", "text/html; charset=utf-8")
304 .into_response(),
305 )
306 } else {
307 let html = create_default_index_html();
308 Ok(
309 warp::reply::with_header(html, "content-type", "text/html; charset=utf-8")
310 .into_response(),
311 )
312 }
313}
314
315async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
325 let path_str = path.as_str();
326
327 if let Some(file) = STATIC_DIR.get_file(path_str) {
329 let content_type = get_content_type(path_str);
330
331 if let Some(contents) = file.contents_utf8() {
332 Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
334 } else {
335 Ok(
337 warp::reply::with_header(file.contents(), "content-type", content_type)
338 .into_response(),
339 )
340 }
341 } else {
342 Err(warp::reject::not_found())
343 }
344}
345
346fn get_content_type(path: &str) -> &'static str {
356 let extension = Path::new(path)
357 .extension()
358 .and_then(|ext| ext.to_str())
359 .unwrap_or("");
360
361 match extension.to_lowercase().as_str() {
362 "html" => "text/html; charset=utf-8",
363 "css" => "text/css; charset=utf-8",
364 "js" => "application/javascript; charset=utf-8",
365 "json" => "application/json; charset=utf-8",
366 "svg" => "image/svg+xml",
367 "png" => "image/png",
368 "jpg" | "jpeg" => "image/jpeg",
369 "gif" => "image/gif",
370 "ico" => "image/x-icon",
371 "txt" => "text/plain; charset=utf-8",
372 "pdf" => "application/pdf",
373 "xml" => "application/xml; charset=utf-8",
374 "woff" => "font/woff",
375 "woff2" => "font/woff2",
376 "ttf" => "font/ttf",
377 "eot" => "application/vnd.ms-fontobject",
378 _ => "application/octet-stream",
379 }
380}
381
382fn create_default_index_html() -> String {
384 r#"<!DOCTYPE html>
385<html lang="en">
386<head>
387 <meta charset="UTF-8">
388 <meta name="viewport" content="width=device-width, initial-scale=1.0">
389 <title>Features Dashboard</title>
390 <style>
391 body {
392 font-family: Arial, sans-serif;
393 margin: 40px;
394 line-height: 1.6;
395 background: #f5f5f5;
396 }
397 .container {
398 max-width: 800px;
399 margin: 0 auto;
400 background: white;
401 padding: 30px;
402 border-radius: 8px;
403 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
404 }
405 h1 { color: #333; }
406 .links { list-style: none; padding: 0; }
407 .links li { margin: 10px 0; }
408 .links a {
409 color: #007acc;
410 text-decoration: none;
411 padding: 8px 15px;
412 border: 1px solid #007acc;
413 border-radius: 4px;
414 display: inline-block;
415 }
416 .links a:hover { background: #007acc; color: white; }
417 </style>
418</head>
419<body>
420 <div class="container">
421 <h1>đī¸ Features Dashboard</h1>
422 <p>Welcome to the feature-based architecture server!</p>
423 <ul class="links">
424 <li><a href="/features.json">đ View Features JSON</a></li>
425 <li><a href="/metadata.json">âšī¸ View Metadata JSON</a></li>
426 </ul>
427 <p><small>This server provides features data and serves embedded static files from the binary.</small></p>
428 </div>
429</body>
430</html>"#.to_string()
431}
432
433async fn handle_rejection(
435 err: warp::Rejection,
436) -> Result<impl warp::Reply, std::convert::Infallible> {
437 let code;
438 let message;
439
440 if err.is_not_found() {
441 code = warp::http::StatusCode::NOT_FOUND;
442 message = "NOT_FOUND";
443 } else if err
444 .find::<warp::filters::body::BodyDeserializeError>()
445 .is_some()
446 {
447 code = warp::http::StatusCode::BAD_REQUEST;
448 message = "BAD_REQUEST";
449 } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
450 code = warp::http::StatusCode::METHOD_NOT_ALLOWED;
451 message = "METHOD_NOT_ALLOWED";
452 } else {
453 eprintln!("Unhandled rejection: {:?}", err);
454 code = warp::http::StatusCode::INTERNAL_SERVER_ERROR;
455 message = "INTERNAL_SERVER_ERROR";
456 }
457
458 Ok(warp::reply::with_status(message, code))
459}