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