1use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use crate::error::{PathErrorExt, SsgError};
11use http_handle::Server;
12
13use crate::cmd;
14use crate::Paths;
15
16pub trait ServeTransport {
23 fn start(&self, addr: &str, root: &str) -> Result<(), SsgError>;
29}
30
31#[derive(Debug, Clone, Copy)]
33pub struct HttpTransport;
34
35impl ServeTransport for HttpTransport {
36 fn start(&self, addr: &str, root: &str) -> Result<(), SsgError> {
37 let server = Server::new(addr, root);
38 let _ = server.start();
39 Ok(())
40 }
41}
42
43pub(crate) fn build_serve_address(
50 site_dir: &Path,
51) -> Result<(String, String), SsgError> {
52 let root = site_dir
53 .to_str()
54 .ok_or_else(|| SsgError::Validation {
55 field: "site_dir".to_string(),
56 message: format!(
57 "Site directory path contains invalid UTF-8: {}",
58 site_dir.display()
59 ),
60 })?
61 .to_string();
62 let addr = format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT);
63 Ok((addr, root))
64}
65
66pub fn serve_site_with<T: ServeTransport>(
98 site_dir: &Path,
99 transport: &T,
100) -> Result<(), SsgError> {
101 let (addr, root) = build_serve_address(site_dir)?;
102 transport.start(&addr, &root)
103}
104
105pub fn serve_site(site_dir: &Path) -> Result<(), SsgError> {
123 serve_site_with(site_dir, &HttpTransport)
124}
125
126pub fn handle_server(
171 log_file: &mut fs::File,
172 date: &str,
173 paths: &Paths,
174 serve_dir: &PathBuf,
175) -> Result<(), SsgError> {
176 handle_server_with(log_file, date, paths, serve_dir, &HttpTransport)
177}
178
179fn handle_server_with<T: ServeTransport>(
185 log_file: &mut fs::File,
186 date: &str,
187 paths: &Paths,
188 serve_dir: &PathBuf,
189 transport: &T,
190) -> Result<(), SsgError> {
191 writeln!(log_file, "[{date}] INFO process: Server initialization")
193 .map_err(|source| SsgError::Io {
194 path: PathBuf::from("log"),
195 source,
196 })?;
197
198 prepare_serve_dir(paths, serve_dir)?;
199
200 let host = cmd::resolve_host();
201 let port = cmd::resolve_port();
202 let addr = format!("{host}:{port}");
203
204 println!("\nStarting server at http://{addr}");
205 println!("Serving content from: {}", serve_dir.display());
206
207 let dir = serve_dir_to_string(serve_dir)?;
208 let bind = addr;
209
210 transport.start(&bind, &dir)
211}
212
213fn serve_dir_to_string(serve_dir: &Path) -> Result<String, SsgError> {
220 serve_dir
221 .to_str()
222 .ok_or_else(|| SsgError::Validation {
223 field: "serve_dir".to_string(),
224 message: "serve dir contains invalid UTF-8".to_string(),
225 })
226 .map(str::to_string)
227}
228
229pub fn generate_locale_redirect(
251 site_dir: &Path,
252 available_locales: &[String],
253 default_locale: &str,
254) -> Result<(), SsgError> {
255 let index_path = site_dir.join("index.html");
256
257 if index_path.exists() {
259 let existing = fs::read_to_string(&index_path).unwrap_or_default();
260 if !existing.contains("<!-- ssg-locale-redirect -->") {
261 return Ok(());
262 }
263 }
264
265 let locales_js: Vec<String> = available_locales
266 .iter()
267 .map(|l| format!("\"{l}\""))
268 .collect();
269 let locales_array = locales_js.join(",");
270 let default_url = format!("/{default_locale}/");
271
272 let html = format!(
273 r#"<!DOCTYPE html>
274<!-- ssg-locale-redirect -->
275<html>
276<head>
277<meta charset="utf-8">
278<script>
279(function() {{
280 var locales = [{locales_array}];
281 var defaultLocale = "{default_locale}";
282 var langs = navigator.languages || [navigator.language || defaultLocale];
283 for (var i = 0; i < langs.length; i++) {{
284 var lang = langs[i].toLowerCase();
285 for (var j = 0; j < locales.length; j++) {{
286 if (lang === locales[j] || lang.startsWith(locales[j] + "-")) {{
287 window.location.replace("/" + locales[j] + "/");
288 return;
289 }}
290 }}
291 var prefix = lang.split("-")[0];
292 for (var j = 0; j < locales.length; j++) {{
293 if (prefix === locales[j]) {{
294 window.location.replace("/" + locales[j] + "/");
295 return;
296 }}
297 }}
298 }}
299 window.location.replace("/" + defaultLocale + "/");
300}})();
301</script>
302<noscript>
303<meta http-equiv="refresh" content="0; url={default_url}">
304</noscript>
305</head>
306<body></body>
307</html>
308"#
309 );
310
311 fs::write(&index_path, &html).with_path(&index_path)?;
312
313 println!(
314 "[i18n] Generated locale redirect at {}",
315 index_path.display()
316 );
317 Ok(())
318}
319
320pub fn prepare_serve_dir(
345 paths: &Paths,
346 serve_dir: &PathBuf,
347) -> Result<(), SsgError> {
348 fs::create_dir_all(serve_dir).with_path(serve_dir)?;
349
350 println!("Setting up server...");
351 println!("Source: {}", paths.site.display());
352 println!("Serving from: {}", serve_dir.display());
353
354 if serve_dir != &paths.site {
355 crate::fs_ops::verify_and_copy_files_async(&paths.site, serve_dir)?;
356 }
357 Ok(())
358}
359
360#[cfg(test)]
361mod tests {
362
363 use super::*;
364 use std::sync::{Arc, Mutex};
365 use tempfile::tempdir;
366
367 #[derive(Default)]
369 struct RecordingTransport {
370 calls: Arc<Mutex<Vec<(String, String)>>>,
371 fail: bool,
372 }
373
374 impl ServeTransport for RecordingTransport {
375 fn start(&self, addr: &str, root: &str) -> Result<(), SsgError> {
376 self.calls
377 .lock()
378 .unwrap()
379 .push((addr.to_string(), root.to_string()));
380 if self.fail {
381 Err(SsgError::Validation {
382 field: "transport".to_string(),
383 message: "synthetic transport failure".to_string(),
384 })
385 } else {
386 Ok(())
387 }
388 }
389 }
390
391 #[test]
392 fn build_serve_address_formats_addr_and_returns_root() {
393 let dir = tempdir().unwrap();
394 let (addr, root) = build_serve_address(dir.path()).unwrap();
395 assert!(
396 addr.contains(cmd::DEFAULT_HOST),
397 "addr should contain default host: {addr}"
398 );
399 assert!(
400 addr.contains(&cmd::DEFAULT_PORT.to_string()),
401 "addr should contain default port: {addr}"
402 );
403 assert_eq!(root, dir.path().to_str().unwrap());
404 }
405
406 #[test]
407 fn serve_site_with_invokes_transport_with_resolved_address() {
408 let dir = tempdir().unwrap();
409 let transport = RecordingTransport::default();
410 let calls = transport.calls.clone();
411 serve_site_with(dir.path(), &transport).unwrap();
412 let recorded = calls.lock().unwrap().clone();
413 assert_eq!(recorded.len(), 1);
414 let (addr, root) = &recorded[0];
415 assert!(addr.contains(cmd::DEFAULT_HOST));
416 assert_eq!(root, dir.path().to_str().unwrap());
417 }
418
419 #[test]
420 fn serve_site_with_propagates_transport_errors() {
421 let dir = tempdir().unwrap();
422 let transport = RecordingTransport {
423 calls: Default::default(),
424 fail: true,
425 };
426 let err = serve_site_with(dir.path(), &transport).unwrap_err();
427 assert!(
428 err.to_string().contains("synthetic transport failure"),
429 "transport error should bubble up, got: {err}"
430 );
431 }
432
433 #[test]
434 #[cfg(unix)]
435 fn serve_site_with_propagates_invalid_utf8_address_error() {
436 use std::os::unix::ffi::OsStringExt;
441 let bad =
442 PathBuf::from(std::ffi::OsString::from_vec(vec![0xff, 0xfe, 0xfd]));
443 let transport = RecordingTransport::default();
444 let err = serve_site_with(&bad, &transport).unwrap_err();
445 assert!(
446 err.to_string().contains("invalid UTF-8"),
447 "unexpected error: {err}"
448 );
449 assert!(
450 transport.calls.lock().unwrap().is_empty(),
451 "transport must not run when address resolution fails"
452 );
453 }
454
455 #[test]
456 fn http_transport_implements_serve_transport() {
457 let _t: &dyn ServeTransport = &HttpTransport;
460 }
461
462 #[test]
463 fn generate_locale_redirect_creates_index_with_marker() {
464 let dir = tempdir().unwrap();
465 generate_locale_redirect(
466 dir.path(),
467 &["en".to_string(), "fr".to_string(), "de".to_string()],
468 "en",
469 )
470 .unwrap();
471
472 let index = dir.path().join("index.html");
473 assert!(index.exists(), "index.html should be written");
474
475 let html = fs::read_to_string(&index).unwrap();
476 assert!(html.contains("<!-- ssg-locale-redirect -->"));
477 assert!(html.contains("\"en\""));
478 assert!(html.contains("\"fr\""));
479 assert!(html.contains("\"de\""));
480 assert!(html.contains("/en/")); }
482
483 #[test]
484 fn generate_locale_redirect_overwrites_own_marker() {
485 let dir = tempdir().unwrap();
486
487 generate_locale_redirect(dir.path(), &["en".to_string()], "en")
489 .unwrap();
490 let first = fs::read_to_string(dir.path().join("index.html")).unwrap();
491
492 generate_locale_redirect(
494 dir.path(),
495 &["en".to_string(), "fr".to_string()],
496 "en",
497 )
498 .unwrap();
499 let second = fs::read_to_string(dir.path().join("index.html")).unwrap();
500
501 assert_ne!(first, second);
502 assert!(second.contains("\"fr\""));
503 }
504
505 #[test]
506 fn generate_locale_redirect_preserves_user_index_html() {
507 let dir = tempdir().unwrap();
509 let user_html = "<html><body>my hand-written page</body></html>";
510 fs::write(dir.path().join("index.html"), user_html).unwrap();
511
512 generate_locale_redirect(dir.path(), &["en".to_string()], "en")
513 .unwrap();
514
515 let after = fs::read_to_string(dir.path().join("index.html")).unwrap();
516 assert_eq!(
517 after, user_html,
518 "user-authored index.html must not be overwritten"
519 );
520 }
521
522 #[test]
523 fn prepare_serve_dir_creates_dir_when_missing() {
524 let dir = tempdir().unwrap();
525 let site = dir.path().join("site");
526 fs::create_dir_all(&site).unwrap();
527 fs::write(site.join("a.html"), "x").unwrap();
528
529 let serve = dir.path().join("serve-out");
530 let paths = Paths {
531 site: site.clone(),
532 content: dir.path().join("content"),
533 build: dir.path().join("build"),
534 template: dir.path().join("templates"),
535 };
536
537 prepare_serve_dir(&paths, &serve).unwrap();
538
539 assert!(serve.exists(), "serve dir should be created");
540 assert!(
541 serve.join("a.html").exists(),
542 "files should be copied from site to serve dir"
543 );
544 }
545
546 #[test]
547 fn prepare_serve_dir_skips_copy_when_serve_equals_site() {
548 let dir = tempdir().unwrap();
549 let site = dir.path().join("site");
550 fs::create_dir_all(&site).unwrap();
551 fs::write(site.join("a.html"), "x").unwrap();
552
553 let paths = Paths {
554 site: site.clone(),
555 content: dir.path().join("content"),
556 build: dir.path().join("build"),
557 template: dir.path().join("templates"),
558 };
559
560 prepare_serve_dir(&paths, &site).unwrap();
562 assert!(site.join("a.html").exists());
563 }
564
565 #[test]
566 fn build_serve_address_contains_host_and_port() {
567 let dir = tempdir().unwrap();
568 let (addr, root) = build_serve_address(dir.path()).unwrap();
569 assert_eq!(
570 addr,
571 format!("{}:{}", cmd::DEFAULT_HOST, cmd::DEFAULT_PORT)
572 );
573 assert_eq!(root, dir.path().to_str().unwrap());
574 }
575
576 #[test]
577 fn serve_site_with_records_correct_root() {
578 let dir = tempdir().unwrap();
579 let sub = dir.path().join("deep").join("nested");
580 fs::create_dir_all(&sub).unwrap();
581 let transport = RecordingTransport::default();
582 let calls = transport.calls.clone();
583 serve_site_with(&sub, &transport).unwrap();
584 let recorded = calls.lock().unwrap();
585 assert_eq!(recorded[0].1, sub.to_str().unwrap());
586 }
587
588 #[test]
589 fn generate_locale_redirect_single_locale() {
590 let dir = tempdir().unwrap();
591 generate_locale_redirect(dir.path(), &["es".to_string()], "es")
592 .unwrap();
593 let html = fs::read_to_string(dir.path().join("index.html")).unwrap();
594 assert!(html.contains("\"es\""));
595 assert!(html.contains("/es/"));
596 assert!(html.contains("<!-- ssg-locale-redirect -->"));
597 }
598
599 #[test]
600 #[cfg(unix)]
601 fn test_handle_server_invalid_utf8_serve_dir() {
602 use std::os::unix::ffi::OsStringExt;
603 let dir = std::ffi::OsString::from_vec(vec![0xff, 0xfe, 0xfd]);
604 let serve_dir = PathBuf::from(dir);
605 let mut log_file = tempfile::tempfile().unwrap();
606 let paths = Paths {
607 site: PathBuf::from("site"),
608 content: PathBuf::from("content"),
609 build: PathBuf::from("build"),
610 template: PathBuf::from("templates"),
611 };
612 let res =
613 handle_server(&mut log_file, "2026-06-06", &paths, &serve_dir);
614 assert!(res.is_err());
615 }
616
617 #[test]
618 fn handle_server_with_drives_transport_after_preparing_dir() {
619 let dir = tempdir().unwrap();
620 let site = dir.path().join("site");
621 fs::create_dir_all(&site).unwrap();
622 fs::write(site.join("a.html"), "x").unwrap();
623 let serve = dir.path().join("serve");
624 let paths = Paths {
625 site,
626 content: dir.path().join("content"),
627 build: dir.path().join("build"),
628 template: dir.path().join("templates"),
629 };
630 let mut log_file = tempfile::tempfile().unwrap();
631 let transport = RecordingTransport::default();
632 let calls = transport.calls.clone();
633
634 handle_server_with(
635 &mut log_file,
636 "2026-07-04",
637 &paths,
638 &serve,
639 &transport,
640 )
641 .unwrap();
642
643 let recorded = calls.lock().unwrap();
644 assert_eq!(recorded.len(), 1, "transport must be started once");
645 assert_eq!(recorded[0].1, serve.to_str().unwrap());
646 assert!(serve.join("a.html").exists(), "site files copied");
647 }
648
649 #[test]
650 fn handle_server_with_fails_when_prepare_serve_dir_errors() {
651 let dir = tempdir().unwrap();
658 let paths = Paths {
659 site: dir.path().join("does-not-exist"),
660 content: dir.path().join("content"),
661 build: dir.path().join("build"),
662 template: dir.path().join("templates"),
663 };
664 let serve = dir.path().join("serve");
665 let mut log_file = tempfile::tempfile().unwrap();
666 let transport = RecordingTransport::default();
667
668 let err = handle_server_with(
669 &mut log_file,
670 "2026-07-05",
671 &paths,
672 &serve,
673 &transport,
674 )
675 .unwrap_err();
676 assert!(
677 format!("{err}").contains("does-not-exist")
678 || format!("{err}").contains("does not exist"),
679 "unexpected error: {err}"
680 );
681 assert!(
682 transport.calls.lock().unwrap().is_empty(),
683 "transport must not start when prepare_serve_dir fails"
684 );
685 }
686
687 #[test]
688 fn handle_server_with_fails_when_log_file_is_read_only() {
689 let dir = tempdir().unwrap();
692 let log_path = dir.path().join("server.log");
693 fs::write(&log_path, "").unwrap();
694 let mut read_only = fs::File::open(&log_path).unwrap();
695
696 let paths = Paths {
697 site: dir.path().join("site"),
698 content: dir.path().join("content"),
699 build: dir.path().join("build"),
700 template: dir.path().join("templates"),
701 };
702 let serve = dir.path().join("serve");
703 let transport = RecordingTransport::default();
704
705 let err = handle_server_with(
706 &mut read_only,
707 "2026-07-04",
708 &paths,
709 &serve,
710 &transport,
711 )
712 .unwrap_err();
713 assert!(format!("{err}").contains("log"), "unexpected error: {err}");
714 assert!(
715 transport.calls.lock().unwrap().is_empty(),
716 "transport must not start when logging fails"
717 );
718 }
719
720 #[test]
721 fn serve_dir_to_string_accepts_utf8_path() {
722 let s = serve_dir_to_string(Path::new("/tmp/serve")).unwrap();
723 assert_eq!(s, "/tmp/serve");
724 }
725
726 #[test]
727 #[cfg(unix)]
728 fn serve_dir_to_string_rejects_invalid_utf8_path() {
729 use std::os::unix::ffi::OsStringExt;
730 let bad =
731 PathBuf::from(std::ffi::OsString::from_vec(vec![0xff, 0xfe, 0xfd]));
732 let err = serve_dir_to_string(&bad).unwrap_err();
733 assert!(format!("{err}").contains("invalid UTF-8"));
734 }
735
736 #[test]
737 #[cfg(unix)]
738 fn generate_locale_redirect_fails_on_readonly_dir() {
739 use std::os::unix::fs::PermissionsExt;
740 let dir = tempdir().unwrap();
741 let site = dir.path().join("frozen");
742 fs::create_dir_all(&site).unwrap();
743 fs::set_permissions(&site, fs::Permissions::from_mode(0o555)).unwrap();
744
745 let res = generate_locale_redirect(&site, &["en".to_string()], "en");
746
747 let _ = fs::set_permissions(&site, fs::Permissions::from_mode(0o755));
748 assert!(res.is_err(), "write into read-only dir must fail");
749 }
750
751 #[test]
752 fn http_transport_start_returns_ok_even_when_bind_fails() {
753 let t = HttpTransport;
757 let res = t.start("not-a-valid-address:zzz", "/tmp");
758 assert!(res.is_ok());
759 }
760}