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(
76 features: &[Feature],
77 port: u16,
78 watch_path: PathBuf,
79 on_ready: Option<Box<dyn FnOnce() + Send>>,
80) -> Result<()> {
81 let config = ServerConfig::new(port);
82 serve_features_with_config_and_watching(features, config, Some(watch_path.clone()), on_ready)
83 .await
84}
85
86pub async fn serve_features_with_config_and_watching(
99 features: &[Feature],
100 config: ServerConfig,
101 watch_path: Option<PathBuf>,
102 on_ready: Option<Box<dyn FnOnce() + Send>>,
103) -> Result<()> {
104 let features_data = Arc::new(RwLock::new(features.to_vec()));
106
107 let repository_url = watch_path
109 .as_ref()
110 .and_then(|path| get_repository_url(path));
111
112 if let Some(ref path) = watch_path {
114 let features_data_clone = Arc::clone(&features_data);
115 let watch_path_clone = path.clone();
116
117 tokio::spawn(async move {
118 if let Err(e) = setup_file_watcher(features_data_clone, watch_path_clone).await {
119 eprintln!("File watcher error: {}", e);
120 }
121 });
122 }
123
124 let features_data_clone = Arc::clone(&features_data);
126 let features_route = warp::path("features.json")
127 .and(warp::get())
128 .and_then(move || {
129 let features_data = Arc::clone(&features_data_clone);
130 async move {
131 let features = features_data.read().await;
132 let features_json = match serde_json::to_string_pretty(&*features) {
133 Ok(json) => json,
134 Err(e) => {
135 eprintln!("Failed to serialize features: {}", e);
136 return Err(warp::reject::custom(SerializationError));
137 }
138 };
139
140 Ok::<_, warp::Rejection>(warp::reply::with_header(
141 features_json,
142 "content-type",
143 "application/json",
144 ))
145 }
146 });
147
148 let metadata_route = warp::path("metadata.json")
150 .and(warp::get())
151 .and_then(move || {
152 let repo_url = repository_url.clone();
153 async move {
154 let mut metadata = serde_json::json!({
155 "version": env!("CARGO_PKG_VERSION")
156 });
157
158 if let Some(url) = repo_url {
160 metadata["repository"] = serde_json::json!(url);
161 }
162
163 let metadata_json = match serde_json::to_string_pretty(&metadata) {
164 Ok(json) => json,
165 Err(e) => {
166 eprintln!("Failed to serialize metadata: {}", e);
167 return Err(warp::reject::custom(SerializationError));
168 }
169 };
170
171 Ok::<_, warp::Rejection>(warp::reply::with_header(
172 metadata_json,
173 "content-type",
174 "application/json",
175 ))
176 }
177 });
178
179 let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
181
182 let static_route = warp::path::tail()
184 .and(warp::get())
185 .and_then(serve_static_file);
186
187 let routes = features_route
188 .or(metadata_route)
189 .or(index_route)
190 .or(static_route)
191 .with(warp::cors().allow_any_origin())
192 .recover(handle_rejection);
193
194 if let Some(callback) = on_ready {
196 callback();
197 }
198
199 println!(
200 "Server running at http://{}:{}",
201 config
202 .host
203 .iter()
204 .map(|&b| b.to_string())
205 .collect::<Vec<_>>()
206 .join("."),
207 config.port,
208 );
209 warp::serve(routes).run((config.host, config.port)).await;
210
211 Ok(())
212}
213
214async fn setup_file_watcher(
216 features_data: Arc<RwLock<Vec<Feature>>>,
217 watch_path: PathBuf,
218) -> Result<()> {
219 let (tx, mut rx) = tokio::sync::mpsc::channel(100);
220
221 let watch_path_clone = watch_path.clone();
223 let _watcher = tokio::task::spawn_blocking(move || -> Result<RecommendedWatcher> {
224 let mut watcher = RecommendedWatcher::new(
225 move |res: notify::Result<Event>| {
226 match res {
227 Ok(event) => {
228 if let Err(e) = tx.blocking_send(event) {
230 eprintln!("Failed to send file system event: {}", e);
231 }
232 }
233 Err(e) => eprintln!("File watcher error: {:?}", e),
234 }
235 },
236 Config::default(),
237 )?;
238
239 watcher.watch(&watch_path_clone, RecursiveMode::Recursive)?;
240 Ok(watcher)
241 })
242 .await??;
243
244 while let Some(event) = rx.recv().await {
246 let should_recompute = event.paths.iter().any(|path| {
248 path.file_name()
249 .map(|name| name == "README.md")
250 .unwrap_or(false)
251 || event.kind.is_create()
252 || event.kind.is_remove()
253 });
254
255 if should_recompute {
256 sleep(Duration::from_millis(500)).await;
258
259 match list_files_recursive_with_changes(&watch_path) {
260 Ok(new_features) => {
261 let mut features = features_data.write().await;
262 *features = new_features;
263 println!("â
Features updated successfully");
264 }
265 Err(e) => {
266 eprintln!("â Failed to recompute features: {}", e);
267 }
268 }
269 }
270 }
271
272 Ok(())
273}
274
275#[derive(Debug)]
277struct SerializationError;
278impl warp::reject::Reject for SerializationError {}
279
280async fn serve_index() -> Result<impl warp::Reply, warp::Rejection> {
289 if let Some(file) = STATIC_DIR.get_file("index.html") {
290 let content = file.contents_utf8().unwrap_or("");
291 Ok(
292 warp::reply::with_header(content, "content-type", "text/html; charset=utf-8")
293 .into_response(),
294 )
295 } else {
296 let html = create_default_index_html();
297 Ok(
298 warp::reply::with_header(html, "content-type", "text/html; charset=utf-8")
299 .into_response(),
300 )
301 }
302}
303
304async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
314 let path_str = path.as_str();
315
316 if let Some(file) = STATIC_DIR.get_file(path_str) {
318 let content_type = get_content_type(path_str);
319
320 if let Some(contents) = file.contents_utf8() {
321 Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
323 } else {
324 Ok(
326 warp::reply::with_header(file.contents(), "content-type", content_type)
327 .into_response(),
328 )
329 }
330 } else {
331 Err(warp::reject::not_found())
332 }
333}
334
335fn get_content_type(path: &str) -> &'static str {
345 let extension = Path::new(path)
346 .extension()
347 .and_then(|ext| ext.to_str())
348 .unwrap_or("");
349
350 match extension.to_lowercase().as_str() {
351 "html" => "text/html; charset=utf-8",
352 "css" => "text/css; charset=utf-8",
353 "js" => "application/javascript; charset=utf-8",
354 "json" => "application/json; charset=utf-8",
355 "svg" => "image/svg+xml",
356 "png" => "image/png",
357 "jpg" | "jpeg" => "image/jpeg",
358 "gif" => "image/gif",
359 "ico" => "image/x-icon",
360 "txt" => "text/plain; charset=utf-8",
361 "pdf" => "application/pdf",
362 "xml" => "application/xml; charset=utf-8",
363 "woff" => "font/woff",
364 "woff2" => "font/woff2",
365 "ttf" => "font/ttf",
366 "eot" => "application/vnd.ms-fontobject",
367 _ => "application/octet-stream",
368 }
369}
370
371fn create_default_index_html() -> String {
373 r#"<!DOCTYPE html>
374<html lang="en">
375<head>
376 <meta charset="UTF-8">
377 <meta name="viewport" content="width=device-width, initial-scale=1.0">
378 <title>Features Dashboard</title>
379 <style>
380 body {
381 font-family: Arial, sans-serif;
382 margin: 40px;
383 line-height: 1.6;
384 background: #f5f5f5;
385 }
386 .container {
387 max-width: 800px;
388 margin: 0 auto;
389 background: white;
390 padding: 30px;
391 border-radius: 8px;
392 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
393 }
394 h1 { color: #333; }
395 .links { list-style: none; padding: 0; }
396 .links li { margin: 10px 0; }
397 .links a {
398 color: #007acc;
399 text-decoration: none;
400 padding: 8px 15px;
401 border: 1px solid #007acc;
402 border-radius: 4px;
403 display: inline-block;
404 }
405 .links a:hover { background: #007acc; color: white; }
406 </style>
407</head>
408<body>
409 <div class="container">
410 <h1>đī¸ Features Dashboard</h1>
411 <p>Welcome to the feature-based architecture server!</p>
412 <ul class="links">
413 <li><a href="/features.json">đ View Features JSON</a></li>
414 <li><a href="/metadata.json">âšī¸ View Metadata JSON</a></li>
415 </ul>
416 <p><small>This server provides features data and serves embedded static files from the binary.</small></p>
417 </div>
418</body>
419</html>"#.to_string()
420}
421
422async fn handle_rejection(
424 err: warp::Rejection,
425) -> Result<impl warp::Reply, std::convert::Infallible> {
426 let code;
427 let message;
428
429 if err.is_not_found() {
430 code = warp::http::StatusCode::NOT_FOUND;
431 message = "NOT_FOUND";
432 } else if err
433 .find::<warp::filters::body::BodyDeserializeError>()
434 .is_some()
435 {
436 code = warp::http::StatusCode::BAD_REQUEST;
437 message = "BAD_REQUEST";
438 } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
439 code = warp::http::StatusCode::METHOD_NOT_ALLOWED;
440 message = "METHOD_NOT_ALLOWED";
441 } else {
442 eprintln!("Unhandled rejection: {:?}", err);
443 code = warp::http::StatusCode::INTERNAL_SERVER_ERROR;
444 message = "INTERNAL_SERVER_ERROR";
445 }
446
447 Ok(warp::reply::with_status(message, code))
448}