合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。 build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。 路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
161 lines
4.5 KiB
Rust
161 lines
4.5 KiB
Rust
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<AppProject> {
|
|
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), "<!doctype html>").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");
|
|
}
|
|
}
|