fob_cli/dev/
config.rs

1//! Development server configuration.
2//!
3//! Extends the base FobConfig with dev-server-specific settings.
4
5use crate::cli::DevArgs;
6use crate::config::FobConfig;
7use crate::error::{ConfigError, Result};
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10
11/// Development server configuration.
12///
13/// Combines base bundler configuration with dev server settings
14/// like port, HTTPS, and watch configuration.
15#[derive(Debug, Clone)]
16pub struct DevConfig {
17    /// Base bundler configuration
18    pub base: FobConfig,
19
20    /// Server socket address (IP + port)
21    pub addr: SocketAddr,
22
23    /// Enable HTTPS with self-signed certificate
24    pub https: bool,
25
26    /// Open browser automatically on start
27    pub open: bool,
28
29    /// Working directory for the dev server
30    pub cwd: PathBuf,
31
32    /// Patterns to ignore when watching files
33    pub watch_ignore: Vec<String>,
34
35    /// Debounce delay in milliseconds for file changes
36    pub debounce_ms: u64,
37}
38
39impl DevConfig {
40    /// Create DevConfig from CLI arguments.
41    ///
42    /// # Arguments
43    ///
44    /// * `args` - Parsed dev command arguments
45    ///
46    /// # Returns
47    ///
48    /// Configured DevConfig instance
49    ///
50    /// # Errors
51    ///
52    /// Returns error if entry point is invalid or configuration is inconsistent
53    pub fn from_args(args: &DevArgs) -> Result<Self> {
54        // Convert DevArgs entry (Option<PathBuf>) to BuildArgs entry (Option<Vec<String>>)
55        // If entry is provided via CLI, use it (will override config file)
56        // If not provided, use None (allows config file to take precedence)
57        let entry_opt = args
58            .entry
59            .as_ref()
60            .map(|entry| vec![entry.to_string_lossy().to_string()]);
61
62        // Create minimal BuildArgs for loading base config
63        let build_args = crate::cli::BuildArgs {
64            entry: entry_opt,
65            format: crate::cli::Format::Esm,
66            out_dir: PathBuf::from("dist"),
67            dts: false,
68            dts_bundle: false,
69            external: vec![],
70            docs: false,
71            docs_format: None,
72            docs_dir: None,
73            docs_include_internal: false,
74            docs_enhance: false,
75            docs_enhance_mode: None,
76            docs_llm_model: None,
77            docs_no_cache: false,
78            docs_llm_url: None,
79            docs_write_back: false,
80            docs_merge_strategy: None,
81            docs_no_backup: false,
82            platform: crate::cli::Platform::Browser,
83            sourcemap: Some(crate::cli::SourceMapMode::External),
84            minify: false,
85            target: crate::cli::EsTarget::Es2020,
86            global_name: None,
87            splitting: false,
88            no_treeshake: false,
89            clean: false,
90            cwd: args.cwd.clone(),
91            bundle: true,
92        };
93
94        // Load base configuration (merges with fob.config.json if present)
95        let base = FobConfig::load(&build_args, None)?;
96
97        // Validate that we have at least one entry from somewhere
98        if base.entry.is_empty() {
99            return Err(crate::error::CliError::InvalidArgument(
100                "No entry point specified. Provide entry via CLI argument or fob.config.json"
101                    .to_string(),
102            ));
103        }
104
105        // Resolve project root using smart auto-detection
106        let cwd = crate::commands::utils::resolve_project_root(
107            base.cwd.as_deref(),                    // explicit --cwd flag
108            base.entry.first().map(String::as_str), // first entry point
109        )?;
110
111        // Try to bind to requested port, fall back to next available
112        let addr = Self::find_available_port(args.port)?;
113
114        // Default ignore patterns for file watching
115        let watch_ignore = vec![
116            "node_modules".to_string(),
117            ".git".to_string(),
118            "dist".to_string(),
119            "build".to_string(),
120            "*.log".to_string(),
121            ".DS_Store".to_string(),
122        ];
123
124        Ok(Self {
125            base,
126            addr,
127            https: args.https,
128            open: args.open,
129            cwd,
130            watch_ignore,
131            debounce_ms: 100, // 100ms debounce
132        })
133    }
134
135    /// Find an available port starting from the requested port.
136    ///
137    /// Tries the requested port first, then incrementally searches
138    /// for the next available port (up to +10 from original).
139    ///
140    /// # Security
141    ///
142    /// - Validates port is not in privileged range (< 1024) unless root
143    /// - Prevents binding to 0.0.0.0 in production mode
144    fn find_available_port(requested_port: u16) -> Result<SocketAddr> {
145        use std::net::TcpListener;
146
147        // Security: warn if using privileged port
148        if requested_port < 1024 {
149            crate::ui::warning(&format!(
150                "Port {} is in privileged range, may require root access",
151                requested_port
152            ));
153        }
154
155        // Try requested port first
156        let addr = SocketAddr::from(([127, 0, 0, 1], requested_port));
157        if TcpListener::bind(addr).is_ok() {
158            return Ok(addr);
159        }
160
161        // Try next 10 ports
162        for offset in 1..=10 {
163            let port = requested_port.saturating_add(offset);
164            let addr = SocketAddr::from(([127, 0, 0, 1], port));
165            if TcpListener::bind(addr).is_ok() {
166                crate::ui::warning(&format!(
167                    "Port {} is busy, using port {} instead",
168                    requested_port, port
169                ));
170                return Ok(addr);
171            }
172        }
173
174        Err(ConfigError::InvalidValue {
175            field: "port".to_string(),
176            value: requested_port.to_string(),
177            hint: format!(
178                "Ports {}-{} are all in use. Try a different port range.",
179                requested_port,
180                requested_port + 10
181            ),
182        }
183        .into())
184    }
185
186    /// Validate the dev server configuration.
187    ///
188    /// # Errors
189    ///
190    /// Returns error if:
191    /// - Entry point doesn't exist
192    /// - Working directory is invalid
193    /// - Base config validation fails
194    pub fn validate(&self) -> Result<()> {
195        // Validate base configuration
196        self.base.validate()?;
197
198        // Validate working directory exists
199        if !self.cwd.exists() {
200            return Err(ConfigError::InvalidValue {
201                field: "cwd".to_string(),
202                value: self.cwd.display().to_string(),
203                hint: "Working directory does not exist".to_string(),
204            }
205            .into());
206        }
207
208        // Validate entry point exists
209        for entry in &self.base.entry {
210            let entry_path = if Path::new(entry).is_absolute() {
211                PathBuf::from(entry)
212            } else {
213                self.cwd.join(entry)
214            };
215
216            if !entry_path.exists() {
217                return Err(ConfigError::InvalidValue {
218                    field: "entry".to_string(),
219                    value: entry.clone(),
220                    hint: format!("Entry point does not exist: {}", entry_path.display()),
221                }
222                .into());
223            }
224        }
225
226        Ok(())
227    }
228
229    /// Get the server URL as a string.
230    pub fn server_url(&self) -> String {
231        let protocol = if self.https { "https" } else { "http" };
232        format!("{}://{}", protocol, self.addr)
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::net::TcpListener;
240
241    #[test]
242    fn test_find_available_port_success() {
243        let listener = match TcpListener::bind(("127.0.0.1", 0)) {
244            Ok(listener) => listener,
245            Err(err) => {
246                eprintln!(
247                    "Skipping test_find_available_port_success: unable to bind socket ({})",
248                    err
249                );
250                return;
251            }
252        };
253
254        let start_port = listener.local_addr().unwrap().port();
255        drop(listener);
256
257        let addr = DevConfig::find_available_port(start_port).expect("should find port");
258        assert_eq!(addr.ip().to_string(), "127.0.0.1");
259        assert!(addr.port() >= start_port);
260    }
261
262    #[test]
263    fn test_find_available_port_privileged_warning() {
264        // Should warn about privileged port but still try to bind
265        let result = DevConfig::find_available_port(80);
266        // May succeed or fail depending on permissions
267        // Just ensure it doesn't panic
268        let _ = result;
269    }
270
271    #[test]
272    fn test_server_url_http() {
273        let config = DevConfig {
274            base: FobConfig {
275                entry: vec!["src/index.ts".to_string()],
276                ..base_config()
277            },
278            addr: "127.0.0.1:3000".parse().unwrap(),
279            https: false,
280            open: false,
281            cwd: PathBuf::from("."),
282            watch_ignore: vec![],
283            debounce_ms: 100,
284        };
285
286        assert_eq!(config.server_url(), "http://127.0.0.1:3000");
287    }
288
289    #[test]
290    fn test_server_url_https() {
291        let config = DevConfig {
292            base: FobConfig {
293                entry: vec!["src/index.ts".to_string()],
294                ..base_config()
295            },
296            addr: "127.0.0.1:3000".parse().unwrap(),
297            https: true,
298            open: false,
299            cwd: PathBuf::from("."),
300            watch_ignore: vec![],
301            debounce_ms: 100,
302        };
303
304        assert_eq!(config.server_url(), "https://127.0.0.1:3000");
305    }
306
307    fn base_config() -> FobConfig {
308        FobConfig {
309            entry: vec![],
310            format: crate::config::Format::Esm,
311            out_dir: PathBuf::from("dist"),
312            dts: false,
313            dts_bundle: None,
314            external: vec![],
315            platform: crate::config::Platform::Browser,
316            sourcemap: None,
317            minify: false,
318            target: crate::config::EsTarget::Es2020,
319            global_name: None,
320            bundle: true,
321            splitting: false,
322            no_treeshake: false,
323            clean: false,
324            cwd: None,
325        }
326    }
327}