Skip to main content

ssg/server/
server.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Dev server infrastructure for the static site generator.
5
6use 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
16/// Pluggable transport that drives the dev server.
17///
18/// Production code uses [`HttpTransport`] (a thin wrapper around
19/// `http_handle::Server`); tests use a test-only `NoopTransport` which
20/// records the call without actually binding a port. The trait exists
21/// so every line of `serve_site` is unit-testable.
22pub trait ServeTransport {
23    /// Start serving `root` on `addr`. Implementations may block.
24    ///
25    /// # Errors
26    ///
27    /// Returns an error if the underlying transport fails to start.
28    fn start(&self, addr: &str, root: &str) -> Result<(), SsgError>;
29}
30
31/// Production transport: starts an `http_handle::Server`.
32#[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
43/// Resolves a `site_dir` `Path` into the `(addr, root)` pair the
44/// transport expects, returning an error if the path contains
45/// invalid UTF-8.
46///
47/// Extracted from `serve_site` so the path-to-string conversion can
48/// be unit-tested without invoking a transport.
49pub(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
66/// Starts the dev server using a caller-supplied transport.
67///
68/// Extracted so test code can pass a no-op transport and still
69/// exercise the surrounding glue (path validation, address
70/// formatting). Production callers use [`serve_site`] which
71/// delegates to [`HttpTransport`].
72///
73/// # Errors
74///
75/// Returns an error if `site_dir` contains invalid UTF-8 or if the
76/// underlying transport fails.
77///
78/// # Examples
79///
80/// ```rust
81/// use ssg::server::{serve_site_with, ServeTransport};
82/// use ssg::SsgError;
83/// use std::path::Path;
84/// use std::cell::RefCell;
85///
86/// struct Recorder(RefCell<Vec<(String, String)>>);
87/// impl ServeTransport for Recorder {
88///     fn start(&self, addr: &str, root: &str) -> Result<(), SsgError> {
89///         self.0.borrow_mut().push((addr.into(), root.into()));
90///         Ok(())
91///     }
92/// }
93/// let rec = Recorder(RefCell::new(vec![]));
94/// serve_site_with(Path::new("public"), &rec).unwrap();
95/// assert_eq!(rec.0.borrow().len(), 1);
96/// ```
97pub 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
105/// Converts a site directory path to a string and starts an HTTP server.
106///
107/// This function blocks while the server is running.
108///
109/// # Examples
110///
111/// ```no_run
112/// use ssg::server::serve_site;
113/// use std::path::Path;
114///
115/// // Blocks until the HTTP server stops — only ever called from `run`.
116/// let _ = serve_site(Path::new("public"));
117/// ```
118///
119/// # Errors
120///
121/// Returns an error if `site_dir` contains invalid UTF-8.
122pub fn serve_site(site_dir: &Path) -> Result<(), SsgError> {
123    serve_site_with(site_dir, &HttpTransport)
124}
125
126/// Configures and launches the development server.
127///
128/// Sets up a local server for testing and previewing the generated site.
129/// Handles file copying and server configuration for local development.
130///
131/// # Arguments
132///
133/// * `log_file` - Reference to the active log file
134/// * `date` - Current timestamp for logging
135/// * `paths` - All required directory paths
136/// * `serve_dir` - Directory to serve content from
137///
138/// # Returns
139///
140/// * `Ok(())` - If server starts successfully
141/// * `Err` - If server configuration or startup fails
142///
143/// # Examples
144///
145/// ```rust,no_run
146/// use std::path::PathBuf;
147/// use ssg::{Paths, handle_server, create_log_file};
148///
149/// fn main() -> Result<(), ssg::error::SsgError> {
150///     let mut log_file = create_log_file("./server.log")?;
151///     let date = ssg::now_iso();
152///     let paths = Paths {
153///         site: PathBuf::from("public"),
154///         content: PathBuf::from("content"),
155///         build: PathBuf::from("build"),
156///         template: PathBuf::from("templates"),
157///     };
158///     let serve_dir = PathBuf::from("serve");
159///
160///     handle_server(&mut log_file, &date, &paths, &serve_dir)?;
161///     Ok(())
162/// }
163/// ```
164///
165/// # Server Configuration
166///
167/// * Default port: 8000
168/// * Host: 127.0.0.1 (localhost)
169/// * Serves static files from the specified directory
170pub 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
179/// Transport-injected body of [`handle_server`].
180///
181/// Extracted so tests can drive every branch (logging, serve-dir
182/// preparation, address resolution) with a recording transport instead
183/// of a blocking `http_handle::Server`.
184fn 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    // Log server initialization
192    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
213/// Converts the serve directory to the `String` root the transport
214/// expects, rejecting non-UTF-8 paths.
215///
216/// Extracted from [`handle_server_with`] so the invalid-UTF-8 branch is
217/// testable in memory — APFS refuses to create non-UTF-8 paths, so the
218/// branch can never be reached through the filesystem on macOS.
219fn 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
229/// Generates a root index.html that reads the browser's language
230/// preference and redirects to the best matching locale directory.
231///
232/// The file is written at `site_dir/index.html`. If it already exists
233/// and was not generated by this function, it is left untouched.
234///
235/// # Examples
236///
237/// ```rust
238/// use ssg::server::generate_locale_redirect;
239/// use tempfile::tempdir;
240///
241/// let dir = tempdir().unwrap();
242/// generate_locale_redirect(dir.path(), &["en".into(), "fr".into()], "en").unwrap();
243/// let html = std::fs::read_to_string(dir.path().join("index.html")).unwrap();
244/// assert!(html.contains("ssg-locale-redirect"));
245/// ```
246///
247/// # Errors
248///
249/// Returns an error if the file cannot be written.
250pub 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 an index.html already exists and wasn't generated by us, leave it.
258    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
320/// Prepares the serve directory by creating it and copying site files.
321///
322/// # Examples
323///
324/// ```rust
325/// use ssg::{Paths, server::prepare_serve_dir};
326/// use std::path::PathBuf;
327/// use tempfile::tempdir;
328/// use std::fs;
329///
330/// let dir = tempdir().unwrap();
331/// let site = dir.path().join("site");
332/// let serve = dir.path().join("serve");
333/// fs::create_dir(&site).unwrap();
334/// fs::write(site.join("a.html"), "<p>").unwrap();
335/// let paths = Paths {
336///     site,
337///     content: PathBuf::from("content"),
338///     build: PathBuf::from("build"),
339///     template: PathBuf::from("templates"),
340/// };
341/// prepare_serve_dir(&paths, &serve).unwrap();
342/// assert!(serve.join("a.html").exists());
343/// ```
344pub 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    /// Test transport that records `(addr, root)` and never blocks.
368    #[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        // `serve_site_with`'s own `build_serve_address(site_dir)?` call
437        // site has its own early-return branch distinct from
438        // `build_serve_address`'s unit tests — exercise it directly so
439        // the transport is never invoked when address resolution fails.
440        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        // Smoke test that HttpTransport satisfies the trait. We don't
458        // actually call .start() here because that would bind a port.
459        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/")); // default fallback
481    }
482
483    #[test]
484    fn generate_locale_redirect_overwrites_own_marker() {
485        let dir = tempdir().unwrap();
486
487        // First call writes the file.
488        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        // Second call with different locales must overwrite.
493        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        // If the user wrote their own index.html (no marker), don't overwrite.
508        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        // serve_dir == site — should not re-copy (no-op).
561        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        // Log write succeeds, but `paths.site` doesn't exist, so
652        // `prepare_serve_dir`'s call to `verify_and_copy_files_async`
653        // errors. This exercises `handle_server_with`'s own
654        // `prepare_serve_dir(paths, serve_dir)?` early-return branch
655        // (distinct from `prepare_serve_dir`'s own unit tests) and
656        // confirms the transport never starts.
657        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        // A read-only handle makes the very first writeln fail,
690        // exercising the log-write error branch.
691        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        // start() swallows the error from server.start() via `let _ =`,
754        // so even an invalid bind address must return Ok(()) — exercises
755        // the body of HttpTransport::start without binding a real port.
756        let t = HttpTransport;
757        let res = t.start("not-a-valid-address:zzz", "/tmp");
758        assert!(res.is_ok());
759    }
760}