use serde::Serialize; use std::{fmt::Write as _, path::Path}; #[derive(Debug, Serialize, Clone, PartialEq, Eq)] pub struct AppProject { pub name: String, pub route_path: String, } impl AppProject { pub fn initial(&self) -> String { self.name .chars() .next() .unwrap_or('A') .to_uppercase() .to_string() } } pub fn scan_projects(root: &Path) -> Vec { let mut projects = Vec::new(); let Ok(read_dir) = std::fs::read_dir(root) else { return projects; }; for item in read_dir.flatten() { let path = item.path(); let name = item.file_name().to_string_lossy().to_string(); if name.starts_with('.') { continue; } if path.is_dir() { let has_index = path.join("index.html").exists() || path.join("index.htm").exists(); if has_index { let route_path = format!("/{}/", encode_path_segment(&name)); projects.push(AppProject { name, route_path }); } } } projects.sort_by(|a, b| { a.name .to_lowercase() .cmp(&b.name.to_lowercase()) .then_with(|| a.name.cmp(&b.name)) }); projects } fn encode_path_segment(segment: &str) -> String { let mut encoded = String::with_capacity(segment.len()); for byte in segment.bytes() { if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { encoded.push(char::from(byte)); } else { write!(&mut encoded, "%{byte:02X}").expect("写入字符串不会失败"); } } encoded } #[cfg(test)] mod tests { use super::*; use std::{ fs, path::PathBuf, sync::atomic::{AtomicU64, Ordering}, }; static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); struct TestDir { path: PathBuf, } impl TestDir { fn new() -> Self { let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); let path = std::env::temp_dir().join(format!("stash-core-test-{}-{id}", std::process::id())); fs::create_dir_all(&path).expect("应能创建测试目录"); Self { path } } fn path(&self) -> &Path { &self.path } fn add_project(&self, name: &str, index_file: &str) { let project = self.path.join(name); fs::create_dir_all(&project).expect("应能创建项目目录"); fs::write(project.join(index_file), "").expect("应能创建入口文件"); } } impl Drop for TestDir { fn drop(&mut self) { let _ = fs::remove_dir_all(&self.path); } } #[test] fn scan_projects_detects_only_immediate_visible_projects() { let root = TestDir::new(); root.add_project("with-html", "index.html"); root.add_project("with-htm", "index.htm"); root.add_project(".hidden", "index.html"); root.add_project("no-index", "readme.txt"); root.add_project("parent/child", "index.html"); fs::create_dir_all(root.path().join("plain-directory")).expect("应能创建普通目录"); let names: Vec<_> = scan_projects(root.path()) .into_iter() .map(|project| project.name) .collect(); assert_eq!(names, vec!["with-htm", "with-html"]); } #[test] fn scan_projects_sorts_names_case_insensitively() { let root = TestDir::new(); root.add_project("zeta", "index.html"); root.add_project("ALPHA", "index.html"); root.add_project("middle", "index.html"); let names: Vec<_> = scan_projects(root.path()) .into_iter() .map(|project| project.name) .collect(); assert_eq!(names, vec!["ALPHA", "middle", "zeta"]); } #[test] fn scan_projects_url_encodes_route_segments() { let root = TestDir::new(); root.add_project("space name", "index.html"); root.add_project("中文", "index.html"); let projects = scan_projects(root.path()); assert_eq!(projects[0].name, "space name"); assert_eq!(projects[0].route_path, "/space%20name/"); assert_eq!(projects[1].name, "中文"); assert_eq!(projects[1].route_path, "/%E4%B8%AD%E6%96%87/"); } #[test] fn path_segment_encoding_preserves_unreserved_characters() { assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25"); } }