Skip to main content

camel_component_http/
static_config.rs

1use std::collections::HashMap;
2use std::fmt;
3use std::path::PathBuf;
4
5use camel_api::component_metadata::ComponentMetadata;
6use camel_component_api::{CamelError, UriComponents, UriConfig, parse_uri};
7use serde::Deserialize;
8use serde::de::{self, Deserializer, MapAccess, Visitor};
9
10/// Configuration for an `http-static:` server endpoint.
11///
12/// Parsed from either a Camel URI (`http-static:/path?port=8080&spaFallback=true`)
13/// or a Camel.toml profile section (`[default.components.http-static]`).
14///
15/// # Config Resolution
16///
17/// Camel.toml profile defaults are applied first, then URI params override
18/// individual fields. Use [`HttpStaticConfig::from_uri_with_defaults`] for
19/// the merged resolution.
20#[derive(Debug, Clone, Deserialize)]
21pub struct HttpStaticConfig {
22    /// Root directory to serve static files from.
23    pub dir: PathBuf,
24
25    /// TCP port to listen on.
26    #[serde(default = "default_port")]
27    pub port: u16,
28
29    /// Bind address.
30    #[serde(default = "default_host")]
31    pub host: String,
32
33    /// Serve `index.html` for unmatched paths (SPA mode).
34    #[serde(rename = "spaFallback", default)]
35    pub spa_fallback: bool,
36
37    /// Cache-Control header value applied to all static responses.
38    #[serde(rename = "cacheControl", default = "default_cache_control")]
39    pub cache_control: String,
40
41    /// Custom error pages mapped by HTTP status code.
42    #[serde(
43        rename = "errorPages",
44        default,
45        deserialize_with = "deserialize_error_pages"
46    )]
47    pub error_pages: HashMap<u16, PathBuf>,
48
49    /// URL path prefix this mount serves on (e.g. "/assets").
50    /// Derived from the URI path in `http-static:/assets?dir=/var/www`.
51    #[serde(default = "default_mount_path")]
52    pub mount_path: String,
53}
54
55fn default_port() -> u16 {
56    8080
57}
58
59/// Custom deserializer for `errorPages` that accepts both string and integer
60/// keys. TOML bare keys are always strings (e.g. `404 = "404.html"`), so we
61/// parse string keys to u16. Integer keys are accepted for robustness (e.g.
62/// from other formats or inline tables).
63fn deserialize_error_pages<'de, D>(deserializer: D) -> Result<HashMap<u16, PathBuf>, D::Error>
64where
65    D: Deserializer<'de>,
66{
67    struct ErrorPagesVisitor;
68
69    impl<'de> Visitor<'de> for ErrorPagesVisitor {
70        type Value = HashMap<u16, PathBuf>;
71
72        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
73            formatter.write_str("a map with integer or string keys representing HTTP status codes")
74        }
75
76        fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
77        where
78            M: MapAccess<'de>,
79        {
80            let mut map = HashMap::new();
81            while let Some(key) = access.next_key::<String>()? {
82                let code: u16 = key.parse().map_err(de::Error::custom)?;
83                let path: PathBuf = access.next_value()?;
84                map.insert(code, path);
85            }
86            Ok(map)
87        }
88    }
89
90    deserializer.deserialize_map(ErrorPagesVisitor)
91}
92
93fn default_host() -> String {
94    "0.0.0.0".to_string()
95}
96
97fn default_cache_control() -> String {
98    "public, max-age=0".to_string()
99}
100
101fn default_mount_path() -> String {
102    "/".to_string()
103}
104
105impl Default for HttpStaticConfig {
106    fn default() -> Self {
107        Self {
108            dir: PathBuf::new(),
109            port: default_port(),
110            host: default_host(),
111            spa_fallback: false,
112            cache_control: default_cache_control(),
113            error_pages: HashMap::new(),
114            mount_path: default_mount_path(),
115        }
116    }
117}
118
119impl UriConfig for HttpStaticConfig {
120    fn scheme() -> &'static str {
121        "http-static"
122    }
123
124    fn from_uri(uri: &str) -> Result<Self, CamelError> {
125        let parts = parse_uri(uri)?;
126        Self::from_components(parts)
127    }
128
129    fn from_components(parts: UriComponents) -> Result<Self, CamelError> {
130        if parts.scheme != "http-static" {
131            return Err(CamelError::InvalidUri(format!(
132                "expected scheme 'http-static', got '{}'",
133                parts.scheme
134            )));
135        }
136
137        // The URI path becomes the mount_path (URL prefix).
138        // If a `dir` query param is present, it overrides the directory.
139        // Otherwise, the URI path itself is the directory (backward compat).
140        let (dir, mount_path) = if parts.path.is_empty() {
141            return Err(CamelError::InvalidUri(
142                "http-static URI requires a path (e.g. http-static:/path or http-static:/prefix?dir=/var/www)"
143                    .to_string(),
144            ));
145        } else if let Some(dir_param) = parts.params.get("dir") {
146            // New style: URI path = mount_path, dir param = directory
147            let mount_path = normalize_mount_path(&parts.path);
148            (PathBuf::from(dir_param), mount_path)
149        } else if parts.path == "/" {
150            // Root mount path with no dir param: the URI path "/" is the mount
151            // point, not the directory. Require explicit dir when serving from root.
152            return Err(CamelError::InvalidUri(
153                "http-static:/ requires a dir query parameter when mount_path is root \
154                 (e.g. http-static:/?dir=/var/www)"
155                    .to_string(),
156            ));
157        } else {
158            // Old style (backward compat): URI path = directory, mount_path = "/"
159            (PathBuf::from(&parts.path), default_mount_path())
160        };
161
162        let port = parts
163            .params
164            .get("port")
165            .map(|v| {
166                v.parse::<u16>()
167                    .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))
168            })
169            .transpose()?
170            .unwrap_or_else(default_port);
171
172        let host = parts
173            .params
174            .get("host")
175            .cloned()
176            .unwrap_or_else(default_host);
177
178        let spa_fallback = parts
179            .params
180            .get("spaFallback")
181            .map(|v| parse_bool_param_static(v))
182            .transpose()?
183            .unwrap_or(false);
184
185        let cache_control = parts
186            .params
187            .get("cacheControl")
188            .cloned()
189            .unwrap_or_else(default_cache_control);
190
191        // errorPages is not supported as a URI param (it's a map);
192        // it comes exclusively from Camel.toml.
193        let error_pages = HashMap::new();
194
195        Ok(Self {
196            dir,
197            port,
198            host,
199            spa_fallback,
200            cache_control,
201            error_pages,
202            mount_path,
203        })
204    }
205}
206
207/// Normalize a mount path: ensure leading `/`, no trailing `/` (except root).
208fn normalize_mount_path(path: &str) -> String {
209    let mut path = path.to_string();
210    if !path.starts_with('/') {
211        path.insert(0, '/');
212    }
213    if path.len() > 1 {
214        path = path.trim_end_matches('/').to_string();
215    }
216    if path.is_empty() {
217        "/".to_string()
218    } else {
219        path
220    }
221}
222
223impl HttpStaticConfig {
224    /// Resolve config from a URI, with TOML-provided defaults applied first.
225    ///
226    /// Fields present in the URI params override the corresponding values from
227    /// `toml_defaults`. Fields absent from the URI retain their TOML value.
228    ///
229    /// # Config Resolution Order
230    ///
231    /// 1. Start with `toml_defaults` (from Camel.toml profile)
232    /// 2. Override individual fields where URI params are present
233    /// 3. Apply hardcoded defaults for any remaining unset fields
234    pub fn from_uri_with_defaults(uri: &str, toml_defaults: &Self) -> Result<Self, CamelError> {
235        let parts = parse_uri(uri)?;
236
237        // Validate scheme to avoid accidentally accepting wrong URIs here.
238        if parts.scheme != "http-static" {
239            return Err(CamelError::InvalidUri(format!(
240                "expected scheme 'http-static', got '{}'",
241                parts.scheme
242            )));
243        }
244
245        // Start from TOML defaults
246        let mut config = toml_defaults.clone();
247
248        // URI path and dir param handling
249        if !parts.path.is_empty() {
250            if let Some(dir_param) = parts.params.get("dir") {
251                // New style: URI path = mount_path, dir param = directory
252                config.dir = PathBuf::from(dir_param);
253                config.mount_path = normalize_mount_path(&parts.path);
254            } else if parts.path == "/" {
255                // Root mount path with no dir param: preserve TOML dir,
256                // only set mount_path (which already defaults to "/").
257                // This handles routes like `http-static:/` where the TOML
258                // config provides the directory (e.g. `dir = "public"`).
259            } else {
260                // Old style (backward compat): URI path = directory
261                config.dir = PathBuf::from(&parts.path);
262                config.mount_path = default_mount_path();
263            }
264        }
265
266        // URI params override individual fields
267        if let Some(v) = parts.params.get("port") {
268            config.port = v
269                .parse::<u16>()
270                .map_err(|e| CamelError::InvalidUri(format!("invalid value for port: {e}")))?;
271        }
272
273        if let Some(v) = parts.params.get("host") {
274            config.host = v.clone();
275        }
276
277        if let Some(v) = parts.params.get("spaFallback") {
278            config.spa_fallback = parse_bool_param_static(v)?;
279        }
280
281        if let Some(v) = parts.params.get("cacheControl") {
282            config.cache_control = v.clone();
283        }
284
285        // If URI had no path and TOML had no dir, apply hardcoded default
286        if config.dir.as_os_str().is_empty() {
287            return Err(CamelError::InvalidUri(
288                "http-static requires a directory path (from URI or Camel.toml)".to_string(),
289            ));
290        }
291
292        Ok(config)
293    }
294}
295
296/// Private container for macro-derived `uri_options()` and `metadata()`.
297///
298/// Mirrors the URI query parameters parsed by `HttpStaticConfig::from_components`.
299/// `HttpStaticConfig` holds typed fields (`PathBuf`, `HashMap`) that the derive
300/// cannot represent, so metadata derivation targets this inner type.
301#[derive(Debug, Clone, UriConfig)]
302#[allow(dead_code)]
303#[uri_scheme = "http-static"]
304#[uri_config(
305    skip_impl,
306    metadata(
307        scheme = "http-static",
308        description = "Static file server component",
309        consumer
310    ),
311    crate = "camel_component_api"
312)]
313struct HttpStaticUriConfig {
314    #[allow(dead_code)]
315    _mount_path: String,
316
317    #[uri_param(name = "dir", desc = "Root directory to serve files from")]
318    dir: String,
319
320    #[uri_param(name = "port", default = "8080", desc = "TCP listen port")]
321    port: u16,
322
323    #[uri_param(name = "host", default = "0.0.0.0", desc = "Bind address")]
324    host: String,
325
326    #[uri_param(
327        name = "spaFallback",
328        default = "false",
329        desc = "Serve index.html for unmatched paths"
330    )]
331    spa_fallback: bool,
332
333    #[uri_param(
334        name = "cacheControl",
335        default = "public, max-age=0",
336        desc = "Cache-Control header value"
337    )]
338    cache_control: String,
339}
340
341impl HttpStaticConfig {
342    /// Component metadata for the http-static scheme, derived from the
343    /// `#[uri_param]` fields on `HttpStaticUriConfig`.
344    pub fn metadata() -> ComponentMetadata {
345        HttpStaticUriConfig::metadata()
346    }
347
348    /// URI option definitions, derived from `#[uri_param]` fields.
349    pub fn uri_options() -> Vec<camel_api::component_metadata::UriOption> {
350        HttpStaticUriConfig::uri_options()
351    }
352}
353
354/// Parse a boolean parameter, case-insensitively.
355///
356/// Accepts: "true"/"True"/"TRUE"/"1"/"yes" as true,
357///          "false"/"False"/"FALSE"/"0"/"no" as false.
358fn parse_bool_param_static(value: &str) -> Result<bool, CamelError> {
359    match value.to_ascii_lowercase().as_str() {
360        "true" | "1" | "yes" => Ok(true),
361        "false" | "0" | "no" => Ok(false),
362        _ => Err(CamelError::InvalidUri(format!(
363            "invalid boolean value: '{value}'"
364        ))),
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    // -----------------------------------------------------------------------
373    // URI parsing tests
374    // -----------------------------------------------------------------------
375
376    #[test]
377    fn test_parse_uri_full() {
378        let config =
379            HttpStaticConfig::from_uri("http-static:/app/spa?port=3000&spaFallback=true").unwrap();
380        assert_eq!(config.dir, PathBuf::from("/app/spa"));
381        assert_eq!(config.port, 3000);
382        assert_eq!(config.host, "0.0.0.0");
383        assert!(config.spa_fallback);
384        assert_eq!(config.cache_control, "public, max-age=0");
385        assert!(config.error_pages.is_empty());
386        assert_eq!(config.mount_path, "/");
387    }
388
389    #[test]
390    fn test_parse_uri_defaults_when_params_omitted() {
391        let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
392        assert_eq!(config.dir, PathBuf::from("/var/www"));
393        assert_eq!(config.port, 8080);
394        assert_eq!(config.host, "0.0.0.0");
395        assert!(!config.spa_fallback);
396        assert_eq!(config.cache_control, "public, max-age=0");
397        assert_eq!(config.mount_path, "/");
398    }
399
400    #[test]
401    fn test_parse_uri_all_params() {
402        let config = HttpStaticConfig::from_uri(
403            "http-static:/app/dist?port=9090&host=127.0.0.1&spaFallback=true&cacheControl=no-cache",
404        )
405        .unwrap();
406        assert_eq!(config.dir, PathBuf::from("/app/dist"));
407        assert_eq!(config.port, 9090);
408        assert_eq!(config.host, "127.0.0.1");
409        assert!(config.spa_fallback);
410        assert_eq!(config.cache_control, "no-cache");
411        assert_eq!(config.mount_path, "/");
412    }
413
414    #[test]
415    fn test_parse_uri_rejects_wrong_scheme() {
416        let result = HttpStaticConfig::from_uri("http:/app/spa");
417        assert!(result.is_err());
418        if let Err(CamelError::InvalidUri(msg)) = result {
419            assert!(msg.contains("expected scheme 'http-static'"));
420            assert!(msg.contains("got 'http'"));
421        } else {
422            panic!("Expected InvalidUri error");
423        }
424    }
425
426    #[test]
427    fn test_parse_uri_rejects_empty_path() {
428        let result = HttpStaticConfig::from_uri("http-static:");
429        assert!(result.is_err());
430        if let Err(CamelError::InvalidUri(msg)) = result {
431            assert!(msg.contains("requires a path"));
432        } else {
433            panic!("Expected InvalidUri error for empty path");
434        }
435    }
436
437    #[test]
438    fn test_parse_uri_invalid_port() {
439        let result = HttpStaticConfig::from_uri("http-static:/app?port=notanumber");
440        assert!(result.is_err());
441        if let Err(CamelError::InvalidUri(msg)) = result {
442            assert!(msg.contains("invalid value for port"));
443        } else {
444            panic!("Expected InvalidUri error for invalid port");
445        }
446    }
447
448    #[test]
449    fn test_parse_uri_boolean_variants() {
450        for val in &["true", "True", "TRUE", "1", "yes"] {
451            let uri = format!("http-static:/app?spaFallback={val}");
452            let config = HttpStaticConfig::from_uri(&uri).unwrap();
453            assert!(
454                config.spa_fallback,
455                "spaFallback='{val}' should parse to true"
456            );
457        }
458        for val in &["false", "False", "FALSE", "0", "no"] {
459            let uri = format!("http-static:/app?spaFallback={val}");
460            let config = HttpStaticConfig::from_uri(&uri).unwrap();
461            assert!(
462                !config.spa_fallback,
463                "spaFallback='{val}' should parse to false"
464            );
465        }
466    }
467
468    #[test]
469    fn test_parse_uri_invalid_boolean() {
470        let result = HttpStaticConfig::from_uri("http-static:/app?spaFallback=maybe");
471        assert!(result.is_err());
472        if let Err(CamelError::InvalidUri(msg)) = result {
473            assert!(msg.contains("invalid boolean value"));
474        } else {
475            panic!("Expected InvalidUri error for invalid boolean");
476        }
477    }
478
479    // -----------------------------------------------------------------------
480    // TOML / serde parsing tests
481    // -----------------------------------------------------------------------
482
483    #[test]
484    fn test_toml_parsing_with_renamed_keys() {
485        let toml_str = r#"
486            dir = "/app/spa"
487            port = 3000
488            host = "127.0.0.1"
489            spaFallback = true
490            cacheControl = "no-cache"
491        "#;
492        let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
493        assert_eq!(config.dir, PathBuf::from("/app/spa"));
494        assert_eq!(config.port, 3000);
495        assert_eq!(config.host, "127.0.0.1");
496        assert!(config.spa_fallback);
497        assert_eq!(config.cache_control, "no-cache");
498    }
499
500    #[test]
501    fn test_toml_error_pages_parsing() {
502        let toml_str = r#"
503            dir = "/app/spa"
504            [errorPages]
505            404 = "/app/errors/404.html"
506            500 = "/app/errors/500.html"
507        "#;
508        let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
509        assert_eq!(config.dir, PathBuf::from("/app/spa"));
510        assert_eq!(config.error_pages.len(), 2);
511        assert_eq!(
512            config.error_pages.get(&404),
513            Some(&PathBuf::from("/app/errors/404.html"))
514        );
515        assert_eq!(
516            config.error_pages.get(&500),
517            Some(&PathBuf::from("/app/errors/500.html"))
518        );
519    }
520
521    #[test]
522    fn test_toml_defaults() {
523        let toml_str = r#"
524            dir = "/app/spa"
525        "#;
526        let config: HttpStaticConfig = toml::from_str(toml_str).unwrap();
527        assert_eq!(config.port, 8080);
528        assert_eq!(config.host, "0.0.0.0");
529        assert!(!config.spa_fallback);
530        assert_eq!(config.cache_control, "public, max-age=0");
531        assert!(config.error_pages.is_empty());
532    }
533
534    // -----------------------------------------------------------------------
535    // from_uri_with_defaults tests (TOML → URI override)
536    // -----------------------------------------------------------------------
537
538    #[test]
539    fn test_uri_overrides_toml_defaults() {
540        let toml_defaults = HttpStaticConfig {
541            dir: PathBuf::from("/default/dir"),
542            port: 8080,
543            host: "0.0.0.0".to_string(),
544            spa_fallback: false,
545            cache_control: "public, max-age=0".to_string(),
546            error_pages: HashMap::new(),
547            ..HttpStaticConfig::default()
548        };
549
550        let config = HttpStaticConfig::from_uri_with_defaults(
551            "http-static:/override/dir?port=3000&spaFallback=true",
552            &toml_defaults,
553        )
554        .unwrap();
555
556        // URI overrides
557        assert_eq!(config.dir, PathBuf::from("/override/dir"));
558        assert_eq!(config.port, 3000);
559        assert!(config.spa_fallback);
560
561        // TOML defaults retained for unspecified fields
562        assert_eq!(config.host, "0.0.0.0");
563        assert_eq!(config.cache_control, "public, max-age=0");
564        assert_eq!(config.mount_path, "/");
565    }
566
567    #[test]
568    fn test_uri_preserves_toml_when_params_absent() {
569        let toml_defaults = HttpStaticConfig {
570            dir: PathBuf::from("/toml/dir"),
571            port: 9090,
572            host: "127.0.0.1".to_string(),
573            spa_fallback: true,
574            cache_control: "no-cache".to_string(),
575            error_pages: HashMap::new(),
576            ..HttpStaticConfig::default()
577        };
578
579        // URI only specifies the path (dir)
580        let config =
581            HttpStaticConfig::from_uri_with_defaults("http-static:/uri/dir", &toml_defaults)
582                .unwrap();
583
584        // dir comes from URI
585        assert_eq!(config.dir, PathBuf::from("/uri/dir"));
586        // Everything else from TOML
587        assert_eq!(config.port, 9090);
588        assert_eq!(config.host, "127.0.0.1");
589        assert!(config.spa_fallback);
590        assert_eq!(config.cache_control, "no-cache");
591        assert_eq!(config.mount_path, "/");
592    }
593
594    #[test]
595    fn test_uri_with_defaults_rejects_empty_dir() {
596        let toml_defaults = HttpStaticConfig {
597            dir: PathBuf::new(), // empty dir in TOML
598            ..HttpStaticConfig::default()
599        };
600
601        let result = HttpStaticConfig::from_uri_with_defaults("http-static:", &toml_defaults);
602        assert!(result.is_err());
603        if let Err(CamelError::InvalidUri(msg)) = result {
604            assert!(msg.contains("directory path"));
605        } else {
606            panic!("Expected InvalidUri error");
607        }
608    }
609
610    #[test]
611    fn test_uri_with_defaults_rejects_wrong_scheme() {
612        let toml_defaults = HttpStaticConfig {
613            dir: PathBuf::from("/default"),
614            ..HttpStaticConfig::default()
615        };
616
617        let result = HttpStaticConfig::from_uri_with_defaults("http:/app", &toml_defaults);
618        assert!(result.is_err());
619        if let Err(CamelError::InvalidUri(msg)) = result {
620            assert!(msg.contains("expected scheme 'http-static'"));
621            assert!(msg.contains("got 'http'"));
622        } else {
623            panic!("Expected InvalidUri error for wrong scheme in from_uri_with_defaults");
624        }
625    }
626
627    // -----------------------------------------------------------------------
628    // mount_path tests
629    // -----------------------------------------------------------------------
630
631    #[test]
632    fn test_mount_path_from_uri_with_dir_param() {
633        // New style: URI path = mount_path, dir param = directory
634        let config = HttpStaticConfig::from_uri("http-static:/assets?dir=/var/www").unwrap();
635        assert_eq!(config.dir, PathBuf::from("/var/www"));
636        assert_eq!(config.mount_path, "/assets");
637    }
638
639    #[test]
640    fn test_mount_path_root_when_no_dir_param() {
641        // Old style: URI path = directory, mount_path = "/"
642        let config = HttpStaticConfig::from_uri("http-static:/var/www").unwrap();
643        assert_eq!(config.dir, PathBuf::from("/var/www"));
644        assert_eq!(config.mount_path, "/");
645    }
646
647    #[test]
648    fn test_mount_path_normalized_leading_slash() {
649        let config = HttpStaticConfig::from_uri("http-static:assets?dir=/var/www").unwrap();
650        assert_eq!(config.mount_path, "/assets");
651    }
652
653    #[test]
654    fn test_mount_path_normalized_no_trailing_slash() {
655        let config = HttpStaticConfig::from_uri("http-static:/assets/?dir=/var/www").unwrap();
656        assert_eq!(config.mount_path, "/assets");
657    }
658
659    #[test]
660    fn test_mount_path_root_stays_root() {
661        let config = HttpStaticConfig::from_uri("http-static:/?dir=/var/www").unwrap();
662        assert_eq!(config.mount_path, "/");
663    }
664
665    #[test]
666    fn test_mount_path_nested() {
667        let config = HttpStaticConfig::from_uri("http-static:/assets/sub?dir=/var/www").unwrap();
668        assert_eq!(config.mount_path, "/assets/sub");
669    }
670
671    #[test]
672    fn test_from_uri_with_defaults_mount_path_with_dir_param() {
673        let toml_defaults = HttpStaticConfig {
674            dir: PathBuf::from("/default/dir"),
675            ..HttpStaticConfig::default()
676        };
677
678        let config = HttpStaticConfig::from_uri_with_defaults(
679            "http-static:/assets?dir=/var/www&port=3000",
680            &toml_defaults,
681        )
682        .unwrap();
683
684        assert_eq!(config.dir, PathBuf::from("/var/www"));
685        assert_eq!(config.mount_path, "/assets");
686        assert_eq!(config.port, 3000);
687    }
688
689    // -----------------------------------------------------------------------
690    // PathBuf parsing validation
691    // -----------------------------------------------------------------------
692
693    #[test]
694    fn test_dir_pathbuf_accepts_various_paths() {
695        // Relative path
696        let config = HttpStaticConfig::from_uri("http-static:./frontend/dist").unwrap();
697        assert_eq!(config.dir, PathBuf::from("./frontend/dist"));
698        assert_eq!(config.mount_path, "/");
699
700        // Absolute path
701        let config = HttpStaticConfig::from_uri("http-static:/var/www/html").unwrap();
702        assert_eq!(config.dir, PathBuf::from("/var/www/html"));
703        assert_eq!(config.mount_path, "/");
704
705        // Path with spaces (percent-encoded)
706        let config = HttpStaticConfig::from_uri("http-static:/app/my%20files").unwrap();
707        assert_eq!(config.dir, PathBuf::from("/app/my files"));
708        assert_eq!(config.mount_path, "/");
709    }
710
711    #[test]
712    fn test_dir_nonexistent_is_detectable() {
713        // Config parsing does NOT validate directory existence — that happens
714        // at consumer start() via std::fs::canonicalize(). This test confirms
715        // that a non-existent path parses successfully at the config layer.
716        let config =
717            HttpStaticConfig::from_uri("http-static:/nonexistent/path/that/does/not/exist")
718                .unwrap();
719        assert_eq!(
720            config.dir,
721            PathBuf::from("/nonexistent/path/that/does/not/exist")
722        );
723        // Validation happens at consumer start, not config parse time.
724    }
725
726    #[test]
727    fn uri_options_count_parity() {
728        assert_eq!(
729            HttpStaticConfig::uri_options().len(),
730            5,
731            "HttpStaticUriConfig #[uri_param] count drifted from parser"
732        );
733    }
734}