features_cli/
http_server.rs

1//! HTTP server module for serving features data and embedded static files.
2//!
3//! This module provides functionality to start an HTTP server that serves:
4//! - Features data as JSON at `/features.json`
5//! - Embedded static files from the compiled binary
6//! - A default index page at the root path
7//!
8//! The server uses the `warp` web framework and supports CORS for cross-origin requests.
9//! Static files are embedded at compile time using the `include_dir` crate.
10
11use 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
25// Embed the public directory at compile time
26static STATIC_DIR: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/public");
27
28/// Configuration for the HTTP server
29#[derive(Debug, Clone)]
30pub struct ServerConfig {
31    /// Port to run the server on
32    pub port: u16,
33    /// Host address to bind to
34    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    /// Create a new server configuration with custom port
48    pub fn new(port: u16) -> Self {
49        Self {
50            port,
51            ..Default::default()
52        }
53    }
54
55    /// Set the host address to bind to
56    #[allow(dead_code)]
57    pub fn with_host(mut self, host: [u8; 4]) -> Self {
58        self.host = host;
59        self
60    }
61}
62
63/// Starts an HTTP server with file watching for a specific directory.
64///
65/// # Arguments
66///
67/// * `features` - Initial Feature objects to serve as JSON
68/// * `port` - Port number to run the server on
69/// * `watch_path` - Path to watch for file changes
70///
71/// # Returns
72///
73/// * `Result<()>` - Ok if server starts successfully, Err otherwise
74pub async fn serve_features_with_watching(
75    features: &[Feature],
76    port: u16,
77    watch_path: PathBuf,
78) -> Result<()> {
79    let config = ServerConfig::new(port);
80    serve_features_with_config_and_watching(features, config, Some(watch_path.clone())).await
81}
82
83/// Starts an HTTP server with custom configuration and optional file watching.
84///
85/// # Arguments
86///
87/// * `features` - Slice of Feature objects to serve as JSON
88/// * `config` - Server configuration
89/// * `watch_path` - Optional path to watch for file changes
90///
91/// # Returns
92///
93/// * `Result<()>` - Ok if server starts successfully, Err otherwise
94pub async fn serve_features_with_config_and_watching(
95    features: &[Feature],
96    config: ServerConfig,
97    watch_path: Option<PathBuf>,
98) -> Result<()> {
99    // Create shared state for features
100    let features_data = Arc::new(RwLock::new(features.to_vec()));
101
102    // Get repository URL from git config
103    let repository_url = watch_path
104        .as_ref()
105        .and_then(|path| get_repository_url(path));
106
107    // Set up file watching if watch_path is provided
108    if let Some(ref path) = watch_path {
109        let features_data_clone = Arc::clone(&features_data);
110        let watch_path_clone = path.clone();
111
112        tokio::spawn(async move {
113            if let Err(e) = setup_file_watcher(features_data_clone, watch_path_clone).await {
114                eprintln!("File watcher error: {}", e);
115            }
116        });
117    }
118
119    // Route for features.json with shared state
120    let features_data_clone = Arc::clone(&features_data);
121    let features_route = warp::path("features.json")
122        .and(warp::get())
123        .and_then(move || {
124            let features_data = Arc::clone(&features_data_clone);
125            async move {
126                let features = features_data.read().await;
127                let features_json = match serde_json::to_string_pretty(&*features) {
128                    Ok(json) => json,
129                    Err(e) => {
130                        eprintln!("Failed to serialize features: {}", e);
131                        return Err(warp::reject::custom(SerializationError));
132                    }
133                };
134
135                Ok::<_, warp::Rejection>(warp::reply::with_header(
136                    features_json,
137                    "content-type",
138                    "application/json",
139                ))
140            }
141        });
142
143    // Route for metadata.json with version info and repository URL
144    let metadata_route = warp::path("metadata.json")
145        .and(warp::get())
146        .and_then(move || {
147            let repo_url = repository_url.clone();
148            async move {
149                let mut metadata = serde_json::json!({
150                    "version": env!("CARGO_PKG_VERSION")
151                });
152
153                // Add repository URL if available
154                if let Some(url) = repo_url {
155                    metadata["repository"] = serde_json::json!(url);
156                }
157
158                let metadata_json = match serde_json::to_string_pretty(&metadata) {
159                    Ok(json) => json,
160                    Err(e) => {
161                        eprintln!("Failed to serialize metadata: {}", e);
162                        return Err(warp::reject::custom(SerializationError));
163                    }
164                };
165
166                Ok::<_, warp::Rejection>(warp::reply::with_header(
167                    metadata_json,
168                    "content-type",
169                    "application/json",
170                ))
171            }
172        });
173
174    // Route for root path to serve index.html
175    let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
176
177    // Route for static files from embedded directory
178    let static_route = warp::path::tail()
179        .and(warp::get())
180        .and_then(serve_static_file);
181
182    let routes = features_route
183        .or(metadata_route)
184        .or(index_route)
185        .or(static_route)
186        .with(warp::cors().allow_any_origin())
187        .recover(handle_rejection);
188
189    println!(
190        "Server running at http://{}:{}",
191        config
192            .host
193            .iter()
194            .map(|&b| b.to_string())
195            .collect::<Vec<_>>()
196            .join("."),
197        config.port,
198    );
199    warp::serve(routes).run((config.host, config.port)).await;
200
201    Ok(())
202}
203
204/// Sets up file system watching for the specified path.
205async fn setup_file_watcher(
206    features_data: Arc<RwLock<Vec<Feature>>>,
207    watch_path: PathBuf,
208) -> Result<()> {
209    let (tx, mut rx) = tokio::sync::mpsc::channel(100);
210
211    // Set up the file watcher in a blocking task
212    let watch_path_clone = watch_path.clone();
213    let _watcher = tokio::task::spawn_blocking(move || -> Result<RecommendedWatcher> {
214        let mut watcher = RecommendedWatcher::new(
215            move |res: notify::Result<Event>| {
216                match res {
217                    Ok(event) => {
218                        // Send event through channel
219                        if let Err(e) = tx.blocking_send(event) {
220                            eprintln!("Failed to send file system event: {}", e);
221                        }
222                    }
223                    Err(e) => eprintln!("File watcher error: {:?}", e),
224                }
225            },
226            Config::default(),
227        )?;
228
229        watcher.watch(&watch_path_clone, RecursiveMode::Recursive)?;
230        Ok(watcher)
231    })
232    .await??;
233
234    // Process file system events
235    while let Some(event) = rx.recv().await {
236        // Check if this is a file we care about (README.md files or directory changes)
237        let should_recompute = event.paths.iter().any(|path| {
238            path.file_name()
239                .map(|name| name == "README.md")
240                .unwrap_or(false)
241                || event.kind.is_create()
242                || event.kind.is_remove()
243        });
244
245        if should_recompute {
246            // Add a small delay to avoid excessive recomputation during rapid changes
247            sleep(Duration::from_millis(500)).await;
248
249            match list_files_recursive_with_changes(&watch_path) {
250                Ok(new_features) => {
251                    let mut features = features_data.write().await;
252                    *features = new_features;
253                    println!("✅ Features updated successfully");
254                }
255                Err(e) => {
256                    eprintln!("❌ Failed to recompute features: {}", e);
257                }
258            }
259        }
260    }
261
262    Ok(())
263}
264
265/// Custom error type for serialization failures
266#[derive(Debug)]
267struct SerializationError;
268impl warp::reject::Reject for SerializationError {}
269
270/// Serves the index page for the root path.
271///
272/// If embedded `index.html` exists, it will be served. Otherwise, a default
273/// HTML page with navigation links will be returned.
274///
275/// # Returns
276///
277/// * `Result<impl warp::Reply, warp::Rejection>` - HTML response or rejection
278async fn serve_index() -> Result<impl warp::Reply, warp::Rejection> {
279    if let Some(file) = STATIC_DIR.get_file("index.html") {
280        let content = file.contents_utf8().unwrap_or("");
281        Ok(
282            warp::reply::with_header(content, "content-type", "text/html; charset=utf-8")
283                .into_response(),
284        )
285    } else {
286        let html = create_default_index_html();
287        Ok(
288            warp::reply::with_header(html, "content-type", "text/html; charset=utf-8")
289                .into_response(),
290        )
291    }
292}
293
294/// Serves static files from the embedded directory.
295///
296/// # Arguments
297///
298/// * `path` - The requested file path
299///
300/// # Returns
301///
302/// * `Result<impl warp::Reply, warp::Rejection>` - File content or rejection
303async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
304    let path_str = path.as_str();
305
306    // Try to get the file from the embedded directory
307    if let Some(file) = STATIC_DIR.get_file(path_str) {
308        let content_type = get_content_type(path_str);
309
310        if let Some(contents) = file.contents_utf8() {
311            // Text file
312            Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
313        } else {
314            // Binary file
315            Ok(
316                warp::reply::with_header(file.contents(), "content-type", content_type)
317                    .into_response(),
318            )
319        }
320    } else {
321        Err(warp::reject::not_found())
322    }
323}
324
325/// Determines the content type based on file extension.
326///
327/// # Arguments
328///
329/// * `path` - The file path
330///
331/// # Returns
332///
333/// * `&'static str` - The appropriate MIME type
334fn get_content_type(path: &str) -> &'static str {
335    let extension = Path::new(path)
336        .extension()
337        .and_then(|ext| ext.to_str())
338        .unwrap_or("");
339
340    match extension.to_lowercase().as_str() {
341        "html" => "text/html; charset=utf-8",
342        "css" => "text/css; charset=utf-8",
343        "js" => "application/javascript; charset=utf-8",
344        "json" => "application/json; charset=utf-8",
345        "svg" => "image/svg+xml",
346        "png" => "image/png",
347        "jpg" | "jpeg" => "image/jpeg",
348        "gif" => "image/gif",
349        "ico" => "image/x-icon",
350        "txt" => "text/plain; charset=utf-8",
351        "pdf" => "application/pdf",
352        "xml" => "application/xml; charset=utf-8",
353        "woff" => "font/woff",
354        "woff2" => "font/woff2",
355        "ttf" => "font/ttf",
356        "eot" => "application/vnd.ms-fontobject",
357        _ => "application/octet-stream",
358    }
359}
360
361/// Creates the default HTML page when no index.html is found.
362fn create_default_index_html() -> String {
363    r#"<!DOCTYPE html>
364<html lang="en">
365<head>
366    <meta charset="UTF-8">
367    <meta name="viewport" content="width=device-width, initial-scale=1.0">
368    <title>Features Dashboard</title>
369    <style>
370        body {
371            font-family: Arial, sans-serif;
372            margin: 40px;
373            line-height: 1.6;
374            background: #f5f5f5;
375        }
376        .container {
377            max-width: 800px;
378            margin: 0 auto;
379            background: white;
380            padding: 30px;
381            border-radius: 8px;
382            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
383        }
384        h1 { color: #333; }
385        .links { list-style: none; padding: 0; }
386        .links li { margin: 10px 0; }
387        .links a {
388            color: #007acc;
389            text-decoration: none;
390            padding: 8px 15px;
391            border: 1px solid #007acc;
392            border-radius: 4px;
393            display: inline-block;
394        }
395        .links a:hover { background: #007acc; color: white; }
396    </style>
397</head>
398<body>
399    <div class="container">
400        <h1>đŸ—ī¸ Features Dashboard</h1>
401        <p>Welcome to the feature-based architecture server!</p>
402        <ul class="links">
403            <li><a href="/features.json">📊 View Features JSON</a></li>
404            <li><a href="/metadata.json">â„šī¸ View Metadata JSON</a></li>
405        </ul>
406        <p><small>This server provides features data and serves embedded static files from the binary.</small></p>
407    </div>
408</body>
409</html>"#.to_string()
410}
411
412/// Handles HTTP request rejections and converts them to appropriate responses.
413async fn handle_rejection(
414    err: warp::Rejection,
415) -> Result<impl warp::Reply, std::convert::Infallible> {
416    let code;
417    let message;
418
419    if err.is_not_found() {
420        code = warp::http::StatusCode::NOT_FOUND;
421        message = "NOT_FOUND";
422    } else if err
423        .find::<warp::filters::body::BodyDeserializeError>()
424        .is_some()
425    {
426        code = warp::http::StatusCode::BAD_REQUEST;
427        message = "BAD_REQUEST";
428    } else if err.find::<warp::reject::MethodNotAllowed>().is_some() {
429        code = warp::http::StatusCode::METHOD_NOT_ALLOWED;
430        message = "METHOD_NOT_ALLOWED";
431    } else {
432        eprintln!("Unhandled rejection: {:?}", err);
433        code = warp::http::StatusCode::INTERNAL_SERVER_ERROR;
434        message = "INTERNAL_SERVER_ERROR";
435    }
436
437    Ok(warp::reply::with_status(message, code))
438}