1use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use anyhow::{anyhow, Context, Result};
11use http_handle::Server;
12
13use crate::cmd;
14use crate::Paths;
15
16pub trait ServeTransport {
23 fn start(&self, addr: &str, root: &str) -> Result<()>;
29}
30
31#[derive(Debug, Clone, Copy)]
33pub struct HttpTransport;
34
35impl ServeTransport for HttpTransport {
36 fn start(&self, addr: &str, root: &str) -> Result<()> {
37 let server = Server::new(addr, root);
38 let _ = server.start();
39 Ok(())
40 }
41}
42
43pub(crate) fn build_serve_address(site_dir: &Path) -> Result<(String, String)> {
50 let root = site_dir
51 .to_str()
52 .ok_or_else(|| {
53 anyhow!(
54 "Site directory path contains invalid UTF-8: {}",
55 site_dir.display()
56 )
57 })?
58 .to_string();
59 let addr = format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT);
60 Ok((addr, root))
61}
62
63pub fn serve_site_with<T: ServeTransport>(
75 site_dir: &Path,
76 transport: &T,
77) -> Result<()> {
78 let (addr, root) = build_serve_address(site_dir)?;
79 transport.start(&addr, &root)
80}
81
82pub fn serve_site(site_dir: &Path) -> Result<()> {
90 serve_site_with(site_dir, &HttpTransport)
91}
92
93pub fn handle_server(
138 log_file: &mut fs::File,
139 date: &str,
140 paths: &Paths,
141 serve_dir: &PathBuf,
142) -> Result<()> {
143 writeln!(log_file, "[{date}] INFO process: Server initialization")?;
145
146 prepare_serve_dir(paths, serve_dir)?;
147
148 let host = cmd::resolve_host();
149 let port = cmd::resolve_port();
150 let addr = format!("{host}:{port}");
151
152 println!("\nStarting server at http://{addr}");
153 println!("Serving content from: {}", serve_dir.display());
154
155 let dir = serve_dir
156 .to_str()
157 .ok_or_else(|| anyhow::anyhow!("serve dir contains invalid UTF-8"))?
158 .to_string();
159 let bind = addr;
160
161 let server = Server::new(&bind, &dir);
162 let _ = server.start();
163 Ok(())
164}
165
166pub fn generate_locale_redirect(
176 site_dir: &Path,
177 available_locales: &[String],
178 default_locale: &str,
179) -> Result<()> {
180 let index_path = site_dir.join("index.html");
181
182 if index_path.exists() {
184 let existing = fs::read_to_string(&index_path).unwrap_or_default();
185 if !existing.contains("<!-- ssg-locale-redirect -->") {
186 return Ok(());
187 }
188 }
189
190 let locales_js: Vec<String> = available_locales
191 .iter()
192 .map(|l| format!("\"{l}\""))
193 .collect();
194 let locales_array = locales_js.join(",");
195 let default_url = format!("/{default_locale}/");
196
197 let html = format!(
198 r#"<!DOCTYPE html>
199<!-- ssg-locale-redirect -->
200<html>
201<head>
202<meta charset="utf-8">
203<script>
204(function() {{
205 var locales = [{locales_array}];
206 var defaultLocale = "{default_locale}";
207 var langs = navigator.languages || [navigator.language || defaultLocale];
208 for (var i = 0; i < langs.length; i++) {{
209 var lang = langs[i].toLowerCase();
210 for (var j = 0; j < locales.length; j++) {{
211 if (lang === locales[j] || lang.startsWith(locales[j] + "-")) {{
212 window.location.replace("/" + locales[j] + "/");
213 return;
214 }}
215 }}
216 var prefix = lang.split("-")[0];
217 for (var j = 0; j < locales.length; j++) {{
218 if (prefix === locales[j]) {{
219 window.location.replace("/" + locales[j] + "/");
220 return;
221 }}
222 }}
223 }}
224 window.location.replace("/" + defaultLocale + "/");
225}})();
226</script>
227<noscript>
228<meta http-equiv="refresh" content="0; url={default_url}">
229</noscript>
230</head>
231<body></body>
232</html>
233"#
234 );
235
236 fs::write(&index_path, &html)
237 .with_context(|| format!("Failed to write {}", index_path.display()))?;
238
239 println!(
240 "[i18n] Generated locale redirect at {}",
241 index_path.display()
242 );
243 Ok(())
244}
245
246pub fn prepare_serve_dir(paths: &Paths, serve_dir: &PathBuf) -> Result<()> {
248 fs::create_dir_all(serve_dir)
249 .context("Failed to create serve directory")?;
250
251 println!("Setting up server...");
252 println!("Source: {}", paths.site.display());
253 println!("Serving from: {}", serve_dir.display());
254
255 if serve_dir != &paths.site {
256 crate::fs_ops::verify_and_copy_files_async(&paths.site, serve_dir)?;
257 }
258 Ok(())
259}
260
261#[cfg(test)]
262#[allow(clippy::unwrap_used, clippy::expect_used)]
263mod tests {
264
265 use super::*;
266 use std::sync::{Arc, Mutex};
267 use tempfile::tempdir;
268
269 #[derive(Default)]
271 struct RecordingTransport {
272 calls: Arc<Mutex<Vec<(String, String)>>>,
273 fail: bool,
274 }
275
276 impl ServeTransport for RecordingTransport {
277 fn start(&self, addr: &str, root: &str) -> Result<()> {
278 self.calls
279 .lock()
280 .unwrap()
281 .push((addr.to_string(), root.to_string()));
282 if self.fail {
283 Err(anyhow!("synthetic transport failure"))
284 } else {
285 Ok(())
286 }
287 }
288 }
289
290 #[test]
291 fn build_serve_address_formats_addr_and_returns_root() {
292 let dir = tempdir().unwrap();
293 let (addr, root) = build_serve_address(dir.path()).unwrap();
294 assert!(
295 addr.contains(cmd::DEFAULT_HOST),
296 "addr should contain default host: {addr}"
297 );
298 assert!(
299 addr.contains(&cmd::DEFAULT_PORT.to_string()),
300 "addr should contain default port: {addr}"
301 );
302 assert_eq!(root, dir.path().to_str().unwrap());
303 }
304
305 #[test]
306 fn serve_site_with_invokes_transport_with_resolved_address() {
307 let dir = tempdir().unwrap();
308 let transport = RecordingTransport::default();
309 let calls = transport.calls.clone();
310 serve_site_with(dir.path(), &transport).unwrap();
311 let recorded = calls.lock().unwrap().clone();
312 assert_eq!(recorded.len(), 1);
313 let (addr, root) = &recorded[0];
314 assert!(addr.contains(cmd::DEFAULT_HOST));
315 assert_eq!(root, dir.path().to_str().unwrap());
316 }
317
318 #[test]
319 fn serve_site_with_propagates_transport_errors() {
320 let dir = tempdir().unwrap();
321 let transport = RecordingTransport {
322 calls: Default::default(),
323 fail: true,
324 };
325 let err = serve_site_with(dir.path(), &transport).unwrap_err();
326 assert!(
327 err.to_string().contains("synthetic transport failure"),
328 "transport error should bubble up, got: {err}"
329 );
330 }
331
332 #[test]
333 fn http_transport_implements_serve_transport() {
334 let _t: &dyn ServeTransport = &HttpTransport;
337 }
338
339 #[test]
340 fn generate_locale_redirect_creates_index_with_marker() {
341 let dir = tempdir().unwrap();
342 generate_locale_redirect(
343 dir.path(),
344 &["en".to_string(), "fr".to_string(), "de".to_string()],
345 "en",
346 )
347 .unwrap();
348
349 let index = dir.path().join("index.html");
350 assert!(index.exists(), "index.html should be written");
351
352 let html = fs::read_to_string(&index).unwrap();
353 assert!(html.contains("<!-- ssg-locale-redirect -->"));
354 assert!(html.contains("\"en\""));
355 assert!(html.contains("\"fr\""));
356 assert!(html.contains("\"de\""));
357 assert!(html.contains("/en/")); }
359
360 #[test]
361 fn generate_locale_redirect_overwrites_own_marker() {
362 let dir = tempdir().unwrap();
363
364 generate_locale_redirect(dir.path(), &["en".to_string()], "en")
366 .unwrap();
367 let first = fs::read_to_string(dir.path().join("index.html")).unwrap();
368
369 generate_locale_redirect(
371 dir.path(),
372 &["en".to_string(), "fr".to_string()],
373 "en",
374 )
375 .unwrap();
376 let second = fs::read_to_string(dir.path().join("index.html")).unwrap();
377
378 assert_ne!(first, second);
379 assert!(second.contains("\"fr\""));
380 }
381
382 #[test]
383 fn generate_locale_redirect_preserves_user_index_html() {
384 let dir = tempdir().unwrap();
386 let user_html = "<html><body>my hand-written page</body></html>";
387 fs::write(dir.path().join("index.html"), user_html).unwrap();
388
389 generate_locale_redirect(dir.path(), &["en".to_string()], "en")
390 .unwrap();
391
392 let after = fs::read_to_string(dir.path().join("index.html")).unwrap();
393 assert_eq!(
394 after, user_html,
395 "user-authored index.html must not be overwritten"
396 );
397 }
398
399 #[test]
400 fn prepare_serve_dir_creates_dir_when_missing() {
401 let dir = tempdir().unwrap();
402 let site = dir.path().join("site");
403 fs::create_dir_all(&site).unwrap();
404 fs::write(site.join("a.html"), "x").unwrap();
405
406 let serve = dir.path().join("serve-out");
407 let paths = Paths {
408 site: site.clone(),
409 content: dir.path().join("content"),
410 build: dir.path().join("build"),
411 template: dir.path().join("templates"),
412 };
413
414 prepare_serve_dir(&paths, &serve).unwrap();
415
416 assert!(serve.exists(), "serve dir should be created");
417 assert!(
418 serve.join("a.html").exists(),
419 "files should be copied from site to serve dir"
420 );
421 }
422
423 #[test]
424 fn prepare_serve_dir_skips_copy_when_serve_equals_site() {
425 let dir = tempdir().unwrap();
426 let site = dir.path().join("site");
427 fs::create_dir_all(&site).unwrap();
428 fs::write(site.join("a.html"), "x").unwrap();
429
430 let paths = Paths {
431 site: site.clone(),
432 content: dir.path().join("content"),
433 build: dir.path().join("build"),
434 template: dir.path().join("templates"),
435 };
436
437 prepare_serve_dir(&paths, &site).unwrap();
439 assert!(site.join("a.html").exists());
440 }
441
442 #[test]
443 fn build_serve_address_contains_host_and_port() {
444 let dir = tempdir().unwrap();
445 let (addr, root) = build_serve_address(dir.path()).unwrap();
446 assert_eq!(
447 addr,
448 format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
449 );
450 assert_eq!(root, dir.path().to_str().unwrap());
451 }
452
453 #[test]
454 fn serve_site_with_records_correct_root() {
455 let dir = tempdir().unwrap();
456 let sub = dir.path().join("deep").join("nested");
457 fs::create_dir_all(&sub).unwrap();
458 let transport = RecordingTransport::default();
459 let calls = transport.calls.clone();
460 serve_site_with(&sub, &transport).unwrap();
461 let recorded = calls.lock().unwrap();
462 assert_eq!(recorded[0].1, sub.to_str().unwrap());
463 }
464
465 #[test]
466 fn generate_locale_redirect_single_locale() {
467 let dir = tempdir().unwrap();
468 generate_locale_redirect(dir.path(), &["es".to_string()], "es")
469 .unwrap();
470 let html = fs::read_to_string(dir.path().join("index.html")).unwrap();
471 assert!(html.contains("\"es\""));
472 assert!(html.contains("/es/"));
473 assert!(html.contains("<!-- ssg-locale-redirect -->"));
474 }
475}