Skip to main content

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/// * `skip_changes` - Whether changes were skipped during feature computation
72///
73/// # Returns
74///
75/// * `Result<()>` - Ok if server starts successfully, Err otherwise
76pub 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
94/// Starts an HTTP server with custom configuration and optional file watching.
95///
96/// # Arguments
97///
98/// * `features` - Slice of Feature objects to serve as JSON
99/// * `config` - Server configuration
100/// * `watch_path` - Optional path to watch for file changes
101/// * `on_ready` - Optional callback to be called when server is ready
102/// * `skip_changes` - Whether changes were skipped during feature computation
103///
104/// # Returns
105///
106/// * `Result<()>` - Ok if server starts successfully, Err otherwise
107pub 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    // Create shared state for features
115    let features_data = Arc::new(RwLock::new(features.to_vec()));
116
117    // Get repository URL from git config
118    let repository_url = watch_path
119        .as_ref()
120        .and_then(|path| get_repository_url(path));
121
122    // Set up file watching if watch_path is provided
123    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    // Route for features.json with shared state
135    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    // Route for metadata.json with version info and repository URL
159    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                // Add repository URL if available
170                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    // Route for root path to serve index.html
191    let index_route = warp::path::end().and(warp::get()).and_then(serve_index);
192
193    // Route for static files from embedded directory
194    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    // Call the ready callback if provided
206    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
225/// Sets up file system watching for the specified path.
226async 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    // Set up the file watcher in a blocking task
233    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                        // Send event through channel
240                        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    // Process file system events
256    while let Some(event) = rx.recv().await {
257        // Check if this is a file we care about (README.md files or directory changes)
258        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            // Add a small delay to avoid excessive recomputation during rapid changes
268            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/// Custom error type for serialization failures
287#[derive(Debug)]
288struct SerializationError;
289impl warp::reject::Reject for SerializationError {}
290
291/// Serves the index page for the root path.
292///
293/// If embedded `index.html` exists, it will be served. Otherwise, a default
294/// HTML page with navigation links will be returned.
295///
296/// # Returns
297///
298/// * `Result<impl warp::Reply, warp::Rejection>` - HTML response or rejection
299async 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
315/// Serves static files from the embedded directory.
316///
317/// # Arguments
318///
319/// * `path` - The requested file path
320///
321/// # Returns
322///
323/// * `Result<impl warp::Reply, warp::Rejection>` - File content or rejection
324async fn serve_static_file(path: warp::path::Tail) -> Result<impl warp::Reply, warp::Rejection> {
325    let path_str = path.as_str();
326
327    // Try to get the file from the embedded directory
328    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            // Text file
333            Ok(warp::reply::with_header(contents, "content-type", content_type).into_response())
334        } else {
335            // Binary file
336            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
346/// Determines the content type based on file extension.
347///
348/// # Arguments
349///
350/// * `path` - The file path
351///
352/// # Returns
353///
354/// * `&'static str` - The appropriate MIME type
355fn 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
382/// Creates the default HTML page when no index.html is found.
383fn 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
433/// Handles HTTP request rejections and converts them to appropriate responses.
434async 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}