Skip to main content

anycms_spa/core/
mod.rs

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/// SPA 配置
24#[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
85/// SPA 响应数据
86pub 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    /// 是否有压缩变体可用
99    #[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    /// 根据 Accept-Encoding 选择最优编码,返回 (Content-Encoding 值, 压缩数据)
113    /// 优先级:br > gzip
114    #[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/// 判断 MIME 类型是否值得压缩
137#[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
150/// 对文本类 MIME 类型追加 `; charset=utf-8`
151pub 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
165/// 格式化 ETag:取 SHA256 前 16 字节 hex 编码,加双引号
166fn format_etag(hash: &[u8; 32]) -> String {
167    let hex: String = hash[..16].iter().map(|b| format!("{:02x}", b)).collect();
168    format!("\"{}\"", hex)
169}
170
171/// 对任意字节计算 ETag(用于 override 文件)
172fn 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
180/// 检查 Accept-Encoding 是否包含 gzip
181pub fn accepts_gzip(accept_encoding: &str) -> bool {
182    accept_encoding.contains("gzip")
183}
184
185/// 检查 Accept-Encoding 是否包含 br(Brotli)
186pub fn accepts_brotli(accept_encoding: &str) -> bool {
187    accept_encoding.contains("br")
188}
189
190/// 检查 If-None-Match 是否匹配当前 ETag
191pub 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/// 解析后的 Range 请求
201#[derive(Debug, Clone, PartialEq)]
202pub struct RangeSpec {
203    pub start: usize,
204    pub end: usize,
205}
206
207impl RangeSpec {
208    /// 解析 `Range: bytes=0-1023` 头,返回 RangeSpec 或 None
209    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        // 暂只支持单段 Range
215        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            // bytes=start-end
226            (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            // bytes=start- (from start to end)
238            (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            // bytes=-suffix (last N bytes)
249            ("", 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    /// 格式化 Content-Range 头值
267    pub fn content_range(&self, total_len: usize) -> String {
268        format!("bytes {}-{}/{}", self.start, self.end, total_len)
269    }
270
271    /// 获取切片范围长度
272    pub fn len(&self) -> usize {
273        self.end - self.start + 1
274    }
275}
276
277/// 检查 If-Range 是否匹配(支持 ETag 或 HTTP-date,这里只处理 ETag)
278pub fn if_range_matches(if_range: &str, etag: &str) -> bool {
279    let if_range = if_range.trim();
280    // ETag 形式:以双引号开头
281    if if_range.starts_with('"') {
282        if_range == etag
283    } else {
284        // HTTP-date 形式:暂不支持,跳过 If-Range 检查
285        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
313/// 递归扫描目录,加载所有文件到 HashMap(相对路径 -> 内容)
314fn 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
352/// SPA 处理器
353pub 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        // 加载 override 文件
397        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    /// 获取嵌入的文件(考虑基础路径)
446    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        // 优先查 override 文件
452        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        // SPA fallback:尝试索引文件
501        for index_file in &self.config.index_files {
502            // 优先查 override
503            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    /// 获取自定义错误页面
548    pub fn get_error_page(&self, status: u16) -> Option<SpaResponse> {
549        let file_path = self.config.error_pages.get(&status)?;
550
551        // 优先查 override
552        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); // 16 hex chars + 2 quotes
623    }
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        // bytes=0-4
673        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        // bytes=5-
681        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        // bytes=-3
689        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()); // start > end
697        assert!(RangeSpec::parse("bytes=10-", 10).is_none()); // start >= total
698        assert!(RangeSpec::parse("bytes=abc-4", 10).is_none()); // non-numeric
699        assert!(RangeSpec::parse("chunks=0-4", 10).is_none()); // wrong unit
700        assert!(RangeSpec::parse("", 10).is_none());
701    }
702
703    #[test]
704    fn test_range_parse_clamp_end() {
705        // end beyond total
706        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        // HTTP-date form -> not supported, returns false
722        assert!(!if_range_matches("Sun, 24 May 2026 00:00:00 GMT", etag));
723    }
724
725    // --- Override tests ---
726
727    #[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        // 创建嵌套文件
768        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        // 创建测试用的临时 override 目录
812        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        // 注意:TestEmbed 没有 test.txt,所以 get_file 会走 fallback
819        // 但 override 中有,所以应该返回 override 内容
820        // 由于 TestEmbed 可能没有嵌入任何文件,我们只验证 override 被加载了
821        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}