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/// * `on_ready` - Optional callback to be called when server is ready
71///
72/// # Returns
73///
74/// * `Result<()>` - Ok if server starts successfully, Err otherwise
75pub 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
86/// Starts an HTTP server with custom configuration and optional file watching.
87///
88/// # Arguments
89///
90/// * `features` - Slice of Feature objects to serve as JSON
91/// * `config` - Server configuration
92/// * `watch_path` - Optional path to watch for file changes
93/// * `on_ready` - Optional callback to be called when server is ready
94///
95/// # Returns
96///
97/// * `Result<()>` - Ok if server starts successfully, Err otherwise
98pub 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    // Create shared state for features
105    let features_data = Arc::new(RwLock::new(features.to_vec()));
106
107    // Get repository URL from git config
108    let repository_url = watch_path
109        .as_ref()
110        .and_then(|path| get_repository_url(path));
111
112    // Set up file watching if watch_path is provided
113    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    // Route for features.json with shared state
125    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    // Route for metadata.json with version info and repository URL
149    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                // Add repository URL if available
159                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    // Route for root path to serve index.html
180    let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
181
182    // Route for static files from embedded directory
183    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    // Call the ready callback if provided
195    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
214/// Sets up file system watching for the specified path.
215async 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    // Set up the file watcher in a blocking task
222    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                        // Send event through channel
229                        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    // Process file system events
245    while let Some(event) = rx.recv().await {
246        // Check if this is a file we care about (README.md files or directory changes)
247        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            // Add a small delay to avoid excessive recomputation during rapid changes
257            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/// Custom error type for serialization failures
276#[derive(Debug)]
277struct SerializationError;
278impl warp::reject::Reject for SerializationError {}
279
280/// Serves the index page for the root path.
281///
282/// If embedded `index.html` exists, it will be served. Otherwise, a default
283/// HTML page with navigation links will be returned.
284///
285/// # Returns
286///
287/// * `Result<impl warp::Reply, warp::Rejection>` - HTML response or rejection
288async 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
304/// Serves static files from the embedded directory.
305///
306/// # Arguments
307///
308/// * `path` - The requested file path
309///
310/// # Returns
311///
312/// * `Result<impl warp::Reply, warp::Rejection>` - File content or rejection
313async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
314    let path_str = path.as_str();
315
316    // Try to get the file from the embedded directory
317    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            // Text file
322            Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
323        } else {
324            // Binary file
325            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
335/// Determines the content type based on file extension.
336///
337/// # Arguments
338///
339/// * `path` - The file path
340///
341/// # Returns
342///
343/// * `&'static str` - The appropriate MIME type
344fn 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
371/// Creates the default HTML page when no index.html is found.
372fn 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
422/// Handles HTTP request rejections and converts them to appropriate responses.
423async 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}