1use crate::cli::DevArgs;
6use crate::config::FobConfig;
7use crate::error::{ConfigError, Result};
8use std::net::SocketAddr;
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, Clone)]
16pub struct DevConfig {
17 pub base: FobConfig,
19
20 pub addr: SocketAddr,
22
23 pub https: bool,
25
26 pub open: bool,
28
29 pub cwd: PathBuf,
31
32 pub watch_ignore: Vec<String>,
34
35 pub debounce_ms: u64,
37}
38
39impl DevConfig {
40 pub fn from_args(args: &DevArgs) -> Result<Self> {
54 let entry_opt = args
58 .entry
59 .as_ref()
60 .map(|entry| vec![entry.to_string_lossy().to_string()]);
61
62 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 let base = FobConfig::load(&build_args, None)?;
96
97 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 let cwd = crate::commands::utils::resolve_project_root(
107 base.cwd.as_deref(), base.entry.first().map(String::as_str), )?;
110
111 let addr = Self::find_available_port(args.port)?;
113
114 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, })
133 }
134
135 fn find_available_port(requested_port: u16) -> Result<SocketAddr> {
145 use std::net::TcpListener;
146
147 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 let addr = SocketAddr::from(([127, 0, 0, 1], requested_port));
157 if TcpListener::bind(addr).is_ok() {
158 return Ok(addr);
159 }
160
161 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 pub fn validate(&self) -> Result<()> {
195 self.base.validate()?;
197
198 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 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 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 let result = DevConfig::find_available_port(80);
266 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}