refactor: 合并为单 crate,前端改为内嵌 React 控制台
合并 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。
This commit is contained in:
+160
@@ -0,0 +1,160 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user