1pub mod path;
2
3use rust_embed::RustEmbed;
4use sha2::{Digest, Sha256};
5use std::borrow::Cow;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use thiserror::Error;
9
10#[derive(Debug, Error)]
11#[non_exhaustive]
12pub enum SpaError {
13 #[error("Resource not found: {0}")]
14 NotFound(String),
15 #[error("MIME type detection failed")]
16 MimeDetection,
17 #[error("Path error: {0}")]
18 PathError(#[from] crate::core::path::PathError),
19 #[error("Index file not found")]
20 IndexFileNotFound,
21}
22
23#[derive(Clone)]
25#[non_exhaustive]
26pub struct SpaConfig {
27 pub base_path: String,
28 pub index_files: Vec<String>,
29 pub security_headers: Vec<(String, String)>,
30 pub error_pages: HashMap<u16, String>,
31 pub override_dir: Option<PathBuf>,
32}
33
34impl Default for SpaConfig {
35 fn default() -> Self {
36 SpaConfig {
37 base_path: "/".to_string(),
38 index_files: vec!["index.html".to_string()],
39 security_headers: Vec::new(),
40 error_pages: HashMap::new(),
41 override_dir: None,
42 }
43 }
44}
45
46impl SpaConfig {
47 pub fn with_base_path(mut self, base_path: &str) -> Self {
48 self.base_path = base_path.to_string();
49 self
50 }
51
52 pub fn with_index_files(mut self, files: &[&str]) -> Self {
53 self.index_files = files.iter().map(|s| s.to_string()).collect();
54 self
55 }
56
57 pub fn add_index_file(mut self, file: &str) -> Self {
58 self.index_files.push(file.to_string());
59 self
60 }
61
62 pub fn with_error_page(mut self, status_code: u16, file_path: &str) -> Self {
63 self.error_pages.insert(status_code, file_path.to_string());
64 self
65 }
66
67 pub fn with_security_header(mut self, key: &str, value: &str) -> Self {
68 self.security_headers.push((key.to_string(), value.to_string()));
69 self
70 }
71
72 pub fn with_override_dir(mut self, dir: impl Into<PathBuf>) -> Self {
73 self.override_dir = Some(dir.into());
74 self
75 }
76
77 pub fn with_default_security_headers(self) -> Self {
78 self.with_security_header("X-Content-Type-Options", "nosniff")
79 .with_security_header("X-Frame-Options", "SAMEORIGIN")
80 .with_security_header("X-XSS-Protection", "1; mode=block")
81 .with_security_header("Referrer-Policy", "strict-origin-when-cross-origin")
82 }
83}
84
85pub struct SpaResponse {
87 pub data: Cow<'static, [u8]>,
88 pub mime: &'static str,
89 pub etag: String,
90 pub is_html: bool,
91 #[cfg(feature = "gzip")]
92 pub gzip_data: Option<Vec<u8>>,
93 #[cfg(feature = "brotli")]
94 pub brotli_data: Option<Vec<u8>>,
95}
96
97impl SpaResponse {
98 #[cfg(any(feature = "gzip", feature = "brotli"))]
100 pub fn has_compression(&self) -> bool {
101 #[cfg(feature = "gzip")]
102 if self.gzip_data.is_some() {
103 return true;
104 }
105 #[cfg(feature = "brotli")]
106 if self.brotli_data.is_some() {
107 return true;
108 }
109 false
110 }
111
112 #[cfg(any(feature = "gzip", feature = "brotli"))]
115 pub fn select_encoding(&self, accept_encoding: &str) -> Option<(&'static str, &[u8])> {
116 #[cfg(feature = "brotli")]
117 {
118 if accepts_brotli(accept_encoding) {
119 if let Some(ref data) = self.brotli_data {
120 return Some(("br", data.as_slice()));
121 }
122 }
123 }
124 #[cfg(feature = "gzip")]
125 {
126 if accepts_gzip(accept_encoding) {
127 if let Some(ref data) = self.gzip_data {
128 return Some(("gzip", data.as_slice()));
129 }
130 }
131 }
132 None
133 }
134}
135
136#[cfg(any(feature = "gzip", feature = "brotli"))]
138fn is_compressible(mime: &str) -> bool {
139 const COMPRESSIBLE: &[&str] = &[
140 "text/",
141 "application/javascript",
142 "application/json",
143 "application/xml",
144 "application/wasm",
145 "image/svg+xml",
146 ];
147 COMPRESSIBLE.iter().any(|prefix| mime.starts_with(prefix))
148}
149
150pub fn content_type_with_charset(mime: &str) -> Cow<'static, str> {
152 const CHARSET_TYPES: &[&str] = &[
153 "text/",
154 "application/javascript",
155 "application/json",
156 "application/xml",
157 ];
158 if CHARSET_TYPES.iter().any(|prefix| mime.starts_with(prefix)) {
159 format!("{}; charset=utf-8", mime).into()
160 } else {
161 mime.to_string().into()
162 }
163}
164
165fn format_etag(hash: &[u8; 32]) -> String {
167 let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
168 format!("\"{}\"", hex)
169}
170
171fn compute_etag(data: &[u8]) -> String {
173 let mut hasher = Sha256::new();
174 hasher.update(data);
175 let result = hasher.finalize();
176 let hash: [u8; 32] = result.into();
177 format_etag(&hash)
178}
179
180pub fn accepts_gzip(accept_encoding: &str) -> bool {
182 accept_encoding.contains("gzip")
183}
184
185pub fn accepts_brotli(accept_encoding: &str) -> bool {
187 accept_encoding.contains("br")
188}
189
190pub fn etag_matches(if_none_match: &str, etag: &str) -> bool {
192 if if_none_match.trim() == "*" {
193 return true;
194 }
195 if_none_match
196 .split(',')
197 .any(|tag| tag.trim() == etag)
198}
199
200#[derive(Debug, Clone, PartialEq)]
202pub struct RangeSpec {
203 pub start: usize,
204 pub end: usize,
205}
206
207impl RangeSpec {
208 pub fn parse(range_header: &str, total_len: usize) -> Option<Self> {
210 let range_header = range_header.trim();
211 let suffix = range_header.strip_prefix("bytes=")?;
212 let suffix = suffix.trim();
213
214 if suffix.contains(',') {
216 return None;
217 }
218
219 let parts: Vec<&str> = suffix.splitn(2, '-').collect();
220 if parts.len() != 2 {
221 return None;
222 }
223
224 let spec = match (parts[0].trim(), parts[1].trim()) {
225 (start_s, end_s) if !start_s.is_empty() && !end_s.is_empty() => {
227 let start: usize = start_s.parse().ok()?;
228 let end: usize = end_s.parse().ok()?;
229 if start > end || start >= total_len {
230 return None;
231 }
232 RangeSpec {
233 start,
234 end: end.min(total_len - 1),
235 }
236 }
237 (start_s, "") if !start_s.is_empty() => {
239 let start: usize = start_s.parse().ok()?;
240 if start >= total_len {
241 return None;
242 }
243 RangeSpec {
244 start,
245 end: total_len - 1,
246 }
247 }
248 ("", suffix_s) if !suffix_s.is_empty() => {
250 let suffix_len: usize = suffix_s.parse().ok()?;
251 if suffix_len == 0 {
252 return None;
253 }
254 let start = total_len.saturating_sub(suffix_len);
255 RangeSpec {
256 start,
257 end: total_len - 1,
258 }
259 }
260 _ => return None,
261 };
262
263 Some(spec)
264 }
265
266 pub fn content_range(&self, total_len: usize) -> String {
268 format!("bytes {}-{}/{}", self.start, self.end, total_len)
269 }
270
271 pub fn len(&self) -> usize {
273 self.end - self.start + 1
274 }
275}
276
277pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
279 let if_range = if_range.trim();
280 if if_range.starts_with('"') {
282 if_range == etag
283 } else {
284 false
286 }
287}
288
289#[cfg(feature = "gzip")]
290fn gzip_compress(data: &[u8]) -> Vec<u8> {
291 use flate2::write::GzEncoder;
292 use flate2::Compression;
293 use std::io::Write;
294
295 let mut encoder = GzEncoder::new(Vec::with_capacity(data.len() / 2), Compression::fast());
296 encoder.write_all(data).expect("gzip compression failed");
297 encoder.finish().expect("gzip finalization failed")
298}
299
300#[cfg(feature = "brotli")]
301fn brotli_compress(data: &[u8]) -> Vec<u8> {
302 use std::io::Write;
303 let mut compressor = brotli::CompressorWriter::new(
304 Vec::with_capacity(data.len() / 2),
305 4096,
306 4,
307 22,
308 );
309 compressor.write_all(data).expect("brotli compression failed");
310 compressor.into_inner()
311}
312
313fn load_overrides(dir: &Path) -> HashMap<String, Vec<u8>> {
315 let mut map = HashMap::new();
316 load_overrides_recursive(dir, dir, &mut map);
317 map
318}
319
320fn load_overrides_recursive(base: &Path, current: &Path, map: &mut HashMap<String, Vec<u8>>) {
321 let Ok(entries) = std::fs::read_dir(current) else {
322 return;
323 };
324 for entry in entries.flatten() {
325 let path = entry.path();
326 if path.is_dir() {
327 load_overrides_recursive(base, &path, map);
328 continue;
329 }
330 let file_name = path.file_name().map(|n| n.to_string_lossy().to_string());
331 if let Some(name) = &file_name {
332 if name.starts_with('.') {
333 continue;
334 }
335 }
336 let Ok(relative) = path.strip_prefix(base) else {
337 continue;
338 };
339 let key = relative.to_string_lossy().to_string();
340 match std::fs::read(&path) {
341 Ok(data) => {
342 tracing::info!("SPA override: loaded {}", key);
343 map.insert(key, data);
344 }
345 Err(e) => {
346 tracing::warn!("SPA override: failed to read {}: {}", key, e);
347 }
348 }
349 }
350}
351
352pub struct SpaHandler<E: RustEmbed> {
354 config: SpaConfig,
355 overrides: HashMap<String, Vec<u8>>,
356 #[cfg(feature = "gzip")]
357 compression_cache: HashMap<String, Vec<u8>>,
358 #[cfg(feature = "brotli")]
359 brotli_cache: HashMap<String, Vec<u8>>,
360 _marker: std::marker::PhantomData<E>,
361}
362
363impl<E: RustEmbed> SpaHandler<E> {
364 pub fn new(config: SpaConfig) -> Self {
365 #[cfg(feature = "gzip")]
366 let mut gzip_cache: HashMap<String, Vec<u8>> = HashMap::new();
367 #[cfg(feature = "brotli")]
368 let mut br_cache: HashMap<String, Vec<u8>> = HashMap::new();
369
370 #[cfg(any(feature = "gzip", feature = "brotli"))]
371 for path in E::iter() {
372 let path_str = path.as_ref();
373 if let Some(file) = E::get(path_str) {
374 let mime = mime_guess::from_path(path_str)
375 .first_raw()
376 .unwrap_or("");
377 if is_compressible(mime) {
378 #[cfg(feature = "gzip")]
379 {
380 let compressed = gzip_compress(&file.data);
381 if compressed.len() < file.data.len() {
382 gzip_cache.insert(path_str.to_string(), compressed);
383 }
384 }
385 #[cfg(feature = "brotli")]
386 {
387 let compressed = brotli_compress(&file.data);
388 if compressed.len() < file.data.len() {
389 br_cache.insert(path_str.to_string(), compressed);
390 }
391 }
392 }
393 }
394 }
395
396 let mut overrides = HashMap::new();
398 if let Some(ref dir) = config.override_dir {
399 if dir.exists() {
400 tracing::info!("SPA override directory: {}", dir.display());
401 overrides = load_overrides(dir);
402 #[cfg(any(feature = "gzip", feature = "brotli"))]
403 for (key, data) in &overrides {
404 let mime = mime_guess::from_path(key.as_str())
405 .first_raw()
406 .unwrap_or("");
407 if is_compressible(mime) {
408 #[cfg(feature = "gzip")]
409 {
410 let compressed = gzip_compress(data);
411 if compressed.len() < data.len() {
412 gzip_cache.insert(key.clone(), compressed);
413 }
414 }
415 #[cfg(feature = "brotli")]
416 {
417 let compressed = brotli_compress(data);
418 if compressed.len() < data.len() {
419 br_cache.insert(key.clone(), compressed);
420 }
421 }
422 }
423 }
424 tracing::info!("SPA override: {} files loaded", overrides.len());
425 } else {
426 tracing::info!("SPA override directory not found, skipping: {}", dir.display());
427 }
428 }
429
430 Self {
431 config,
432 overrides,
433 #[cfg(feature = "gzip")]
434 compression_cache: gzip_cache,
435 #[cfg(feature = "brotli")]
436 brotli_cache: br_cache,
437 _marker: std::marker::PhantomData,
438 }
439 }
440
441 pub fn security_headers(&self) -> &[(String, String)] {
442 &self.config.security_headers
443 }
444
445 pub fn get_file(&self, request_path: &str) -> Result<SpaResponse, SpaError> {
447 let clean_path = crate::core::path::collapse_slashes(request_path);
448 let normalized_path = crate::core::path::normalize_path(&clean_path)?;
449 let resource_path = crate::core::path::relative_to_base(&normalized_path, &self.config.base_path);
450
451 if let Some(data) = self.overrides.get(&resource_path) {
453 let mime = mime_guess::from_path(&resource_path)
454 .first_raw()
455 .unwrap_or("application/octet-stream");
456 let is_html = mime.starts_with("text/html");
457 let etag = compute_etag(data);
458
459 #[cfg(feature = "gzip")]
460 let gzip_data = self.compression_cache.get(&resource_path).cloned();
461 #[cfg(feature = "brotli")]
462 let brotli_data = self.brotli_cache.get(&resource_path).cloned();
463
464 return Ok(SpaResponse {
465 data: Cow::Owned(data.clone()),
466 mime,
467 etag,
468 is_html,
469 #[cfg(feature = "gzip")]
470 gzip_data,
471 #[cfg(feature = "brotli")]
472 brotli_data,
473 });
474 }
475
476 if let Some(content) = E::get(&resource_path) {
477 let mime = mime_guess::from_path(&resource_path)
478 .first_raw()
479 .unwrap_or("application/octet-stream");
480 let is_html = mime.starts_with("text/html");
481 let etag = format_etag(&content.metadata.sha256_hash());
482
483 #[cfg(feature = "gzip")]
484 let gzip_data = self.compression_cache.get(&resource_path).cloned();
485 #[cfg(feature = "brotli")]
486 let brotli_data = self.brotli_cache.get(&resource_path).cloned();
487
488 return Ok(SpaResponse {
489 data: content.data,
490 mime,
491 etag,
492 is_html,
493 #[cfg(feature = "gzip")]
494 gzip_data,
495 #[cfg(feature = "brotli")]
496 brotli_data,
497 });
498 }
499
500 for index_file in &self.config.index_files {
502 if let Some(data) = self.overrides.get(index_file) {
504 let etag = compute_etag(data);
505
506 #[cfg(feature = "gzip")]
507 let gzip_data = self.compression_cache.get(index_file).cloned();
508 #[cfg(feature = "brotli")]
509 let brotli_data = self.brotli_cache.get(index_file).cloned();
510
511 return Ok(SpaResponse {
512 data: Cow::Owned(data.clone()),
513 mime: "text/html",
514 etag,
515 is_html: true,
516 #[cfg(feature = "gzip")]
517 gzip_data,
518 #[cfg(feature = "brotli")]
519 brotli_data,
520 });
521 }
522
523 if let Some(content) = E::get(index_file) {
524 let etag = format_etag(&content.metadata.sha256_hash());
525
526 #[cfg(feature = "gzip")]
527 let gzip_data = self.compression_cache.get(index_file).cloned();
528 #[cfg(feature = "brotli")]
529 let brotli_data = self.brotli_cache.get(index_file).cloned();
530
531 return Ok(SpaResponse {
532 data: content.data,
533 mime: "text/html",
534 etag,
535 is_html: true,
536 #[cfg(feature = "gzip")]
537 gzip_data,
538 #[cfg(feature = "brotli")]
539 brotli_data,
540 });
541 }
542 }
543
544 Err(SpaError::IndexFileNotFound)
545 }
546
547 pub fn get_error_page(&self, status: u16) -> Option<SpaResponse> {
549 let file_path = self.config.error_pages.get(&status)?;
550
551 if let Some(data) = self.overrides.get(file_path) {
553 let mime = mime_guess::from_path(file_path.as_str())
554 .first_raw()
555 .unwrap_or("text/html");
556 let etag = compute_etag(data);
557
558 #[cfg(feature = "gzip")]
559 let gzip_data = self.compression_cache.get(file_path).cloned();
560 #[cfg(feature = "brotli")]
561 let brotli_data = self.brotli_cache.get(file_path).cloned();
562
563 return Some(SpaResponse {
564 data: Cow::Owned(data.clone()),
565 mime,
566 etag,
567 is_html: mime.starts_with("text/html"),
568 #[cfg(feature = "gzip")]
569 gzip_data,
570 #[cfg(feature = "brotli")]
571 brotli_data,
572 });
573 }
574
575 let content = E::get(file_path)?;
576 let mime = mime_guess::from_path(file_path.as_str())
577 .first_raw()
578 .unwrap_or("text/html");
579 let etag = format_etag(&content.metadata.sha256_hash());
580
581 #[cfg(feature = "gzip")]
582 let gzip_data = self.compression_cache.get(file_path).cloned();
583 #[cfg(feature = "brotli")]
584 let brotli_data = self.brotli_cache.get(file_path).cloned();
585
586 Some(SpaResponse {
587 data: content.data,
588 mime,
589 etag,
590 is_html: mime.starts_with("text/html"),
591 #[cfg(feature = "gzip")]
592 gzip_data,
593 #[cfg(feature = "brotli")]
594 brotli_data,
595 })
596 }
597}
598
599#[cfg(test)]
600mod tests {
601 use super::*;
602
603 #[cfg(feature = "gzip")]
604 #[test]
605 fn test_is_compressible() {
606 assert!(is_compressible("text/html"));
607 assert!(is_compressible("text/css"));
608 assert!(is_compressible("application/javascript"));
609 assert!(is_compressible("application/json"));
610 assert!(is_compressible("image/svg+xml"));
611 assert!(!is_compressible("image/png"));
612 assert!(!is_compressible("image/jpeg"));
613 assert!(!is_compressible("font/woff2"));
614 }
615
616 #[test]
617 fn test_format_etag() {
618 let hash = [0xab; 32];
619 let etag = format_etag(&hash);
620 assert!(etag.starts_with('"'));
621 assert!(etag.ends_with('"'));
622 assert_eq!(etag.len(), 34); }
624
625 #[test]
626 fn test_etag_matches() {
627 let etag = "\"abc123\"";
628 assert!(etag_matches("\"abc123\"", etag));
629 assert!(etag_matches("\"abc123\", \"def456\"", etag));
630 assert!(etag_matches("*", etag));
631 assert!(!etag_matches("\"def456\"", etag));
632 }
633
634 #[test]
635 fn test_accepts_gzip() {
636 assert!(accepts_gzip("gzip"));
637 assert!(accepts_gzip("gzip, deflate, br"));
638 assert!(accepts_gzip("deflate, gzip"));
639 assert!(!accepts_gzip("deflate, br"));
640 assert!(!accepts_gzip(""));
641 }
642
643 #[test]
644 fn test_content_type_with_charset() {
645 assert_eq!(content_type_with_charset("text/html"), "text/html; charset=utf-8");
646 assert_eq!(content_type_with_charset("text/css"), "text/css; charset=utf-8");
647 assert_eq!(content_type_with_charset("application/javascript"), "application/javascript; charset=utf-8");
648 assert_eq!(content_type_with_charset("application/json"), "application/json; charset=utf-8");
649 assert_eq!(content_type_with_charset("image/png"), "image/png");
650 assert_eq!(content_type_with_charset("application/wasm"), "application/wasm");
651 }
652
653 #[test]
654 fn test_accepts_brotli() {
655 assert!(accepts_brotli("br"));
656 assert!(accepts_brotli("gzip, deflate, br"));
657 assert!(accepts_brotli("br, gzip"));
658 assert!(!accepts_brotli("gzip, deflate"));
659 assert!(!accepts_brotli(""));
660 }
661
662 #[cfg(feature = "brotli")]
663 #[test]
664 fn test_brotli_compress() {
665 let data = b"hello world hello world hello world hello world";
666 let compressed = brotli_compress(data);
667 assert!(compressed.len() < data.len());
668 }
669
670 #[test]
671 fn test_range_parse_start_end() {
672 let spec = RangeSpec::parse("bytes=0-4", 10).unwrap();
674 assert_eq!(spec, RangeSpec { start: 0, end: 4 });
675 assert_eq!(spec.len(), 5);
676 }
677
678 #[test]
679 fn test_range_parse_start_open() {
680 let spec = RangeSpec::parse("bytes=5-", 10).unwrap();
682 assert_eq!(spec, RangeSpec { start: 5, end: 9 });
683 assert_eq!(spec.len(), 5);
684 }
685
686 #[test]
687 fn test_range_parse_suffix() {
688 let spec = RangeSpec::parse("bytes=-3", 10).unwrap();
690 assert_eq!(spec, RangeSpec { start: 7, end: 9 });
691 assert_eq!(spec.len(), 3);
692 }
693
694 #[test]
695 fn test_range_parse_invalid() {
696 assert!(RangeSpec::parse("bytes=5-3", 10).is_none()); assert!(RangeSpec::parse("bytes=10-", 10).is_none()); assert!(RangeSpec::parse("bytes=abc-4", 10).is_none()); assert!(RangeSpec::parse("chunks=0-4", 10).is_none()); assert!(RangeSpec::parse("", 10).is_none());
701 }
702
703 #[test]
704 fn test_range_parse_clamp_end() {
705 let spec = RangeSpec::parse("bytes=0-999", 100).unwrap();
707 assert_eq!(spec, RangeSpec { start: 0, end: 99 });
708 }
709
710 #[test]
711 fn test_range_content_range() {
712 let spec = RangeSpec { start: 0, end: 4 };
713 assert_eq!(spec.content_range(10), "bytes 0-4/10");
714 }
715
716 #[test]
717 fn test_if_range_matches() {
718 let etag = "\"abc123\"";
719 assert!(if_range_matches("\"abc123\"", etag));
720 assert!(!if_range_matches("\"def456\"", etag));
721 assert!(!if_range_matches("Sun, 24 May 2026 00:00:00 GMT", etag));
723 }
724
725 #[test]
728 fn test_compute_etag() {
729 let data = b"hello";
730 let etag1 = compute_etag(data);
731 let etag2 = compute_etag(data);
732 assert_eq!(etag1, etag2, "ETag should be deterministic");
733 assert!(etag1.starts_with('"'));
734 assert!(etag1.ends_with('"'));
735 }
736
737 #[test]
738 fn test_compute_etag_different_data() {
739 let etag1 = compute_etag(b"hello");
740 let etag2 = compute_etag(b"world");
741 assert_ne!(etag1, etag2, "Different data should produce different ETags");
742 }
743
744 fn temp_dir_with_name(name: &str) -> PathBuf {
745 let dir = std::env::temp_dir().join(format!("anycms-spa-test-{}-{}", name, std::process::id()));
746 let _ = std::fs::remove_dir_all(&dir);
747 std::fs::create_dir_all(&dir).unwrap();
748 dir
749 }
750
751 fn cleanup_dir(dir: &Path) {
752 let _ = std::fs::remove_dir_all(dir);
753 }
754
755 #[test]
756 fn test_load_overrides_empty_dir() {
757 let dir = temp_dir_with_name("empty");
758 let map = load_overrides(&dir);
759 assert!(map.is_empty());
760 cleanup_dir(&dir);
761 }
762
763 #[test]
764 fn test_load_overrides_with_files() {
765 let dir = temp_dir_with_name("files");
766
767 std::fs::write(dir.join("index.html"), b"<h1>override</h1>").unwrap();
769 std::fs::create_dir_all(dir.join("css")).unwrap();
770 std::fs::write(dir.join("css/style.css"), b"body { color: red; }").unwrap();
771 std::fs::create_dir_all(dir.join("js")).unwrap();
772 std::fs::write(dir.join("js/app.js"), b"console.log('override');").unwrap();
773
774 let map = load_overrides(&dir);
775 assert_eq!(map.len(), 3);
776 assert_eq!(map.get("index.html").unwrap(), b"<h1>override</h1>");
777 assert_eq!(map.get("css/style.css").unwrap(), b"body { color: red; }");
778 assert_eq!(map.get("js/app.js").unwrap(), b"console.log('override');");
779
780 cleanup_dir(&dir);
781 }
782
783 #[test]
784 fn test_load_overrides_ignores_hidden() {
785 let dir = temp_dir_with_name("hidden");
786
787 std::fs::write(dir.join("visible.txt"), b"visible").unwrap();
788 std::fs::write(dir.join(".hidden"), b"hidden").unwrap();
789
790 let map = load_overrides(&dir);
791 assert_eq!(map.len(), 1);
792 assert!(map.contains_key("visible.txt"));
793 assert!(!map.contains_key(".hidden"));
794
795 cleanup_dir(&dir);
796 }
797
798 #[test]
799 fn test_load_overrides_nonexistent_dir() {
800 let dir = PathBuf::from("/tmp/anycms-spa-nonexistent-12345");
801 let map = load_overrides(&dir);
802 assert!(map.is_empty());
803 }
804
805 #[derive(rust_embed::RustEmbed)]
806 #[folder = "tests-assets"]
807 struct TestEmbed;
808
809 #[test]
810 fn test_override_takes_priority() {
811 let dir = temp_dir_with_name("priority");
813 std::fs::write(dir.join("test.txt"), b"override content").unwrap();
814
815 let config = SpaConfig::default().with_override_dir(&dir);
816 let handler: SpaHandler<TestEmbed> = SpaHandler::new(config);
817
818 assert!(handler.overrides.contains_key("test.txt"));
822 assert_eq!(handler.overrides.get("test.txt").unwrap(), b"override content");
823
824 cleanup_dir(&dir);
825 }
826
827 #[test]
828 fn test_override_etag_consistency() {
829 let dir = temp_dir_with_name("etag");
830 std::fs::write(dir.join("style.css"), b"body { margin: 0; }").unwrap();
831
832 let config = SpaConfig::default().with_override_dir(&dir);
833 let handler: SpaHandler<TestEmbed> = SpaHandler::new(config);
834
835 let data = handler.overrides.get("style.css").unwrap();
836 let etag1 = compute_etag(data);
837 let etag2 = compute_etag(data);
838 assert_eq!(etag1, etag2);
839
840 cleanup_dir(&dir);
841 }
842
843 #[test]
844 fn test_no_override_dir() {
845 let config = SpaConfig::default();
846 let handler: SpaHandler<TestEmbed> = SpaHandler::new(config);
847 assert!(handler.overrides.is_empty());
848 }
849
850 #[test]
851 fn test_override_dir_not_exists() {
852 let config = SpaConfig::default().with_override_dir("/tmp/anycms-spa-no-such-dir-99999");
853 let handler: SpaHandler<TestEmbed> = SpaHandler::new(config);
854 assert!(handler.overrides.is_empty());
855 }
856}