feat(boss-engine): 增加项目派生命令

新增 fork-boss-engine,从 AbyssBossEngine 源项目生成独立 BOSS 模组,并重建 UUID、命名空间和脚本包。

校验项目名称与命名空间,同时阻止输出目录落入源目录,避免路径穿越和递归复制。
This commit is contained in:
2026-07-21 20:17:49 +08:00
parent 410b2e64e9
commit bc9221d4cc
5 changed files with 657 additions and 0 deletions

35
src/boss_engine/copy.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::error::Result;
use std::path::Path;
/// 递归复制项目目录,排除指定的目录名和文件名。用于 fork 的项目复制。
pub fn copy_dir_filtered(
src: &Path,
dst: &Path,
exclude_dirs: &[&str],
exclude_files: &[&str],
) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let entry_path = entry.path();
let file_name = entry.file_name();
let name_str = file_name.to_string_lossy();
if entry_path.is_dir() {
if exclude_dirs.contains(&name_str.as_ref()) {
continue;
}
let dest_path = dst.join(&file_name);
copy_dir_filtered(&entry_path, &dest_path, exclude_dirs, exclude_files)?;
} else if entry_path.is_file() {
if exclude_files.contains(&name_str.as_ref()) {
continue;
}
let dest_path = dst.join(&file_name);
std::fs::copy(&entry_path, &dest_path)?;
}
}
Ok(())
}

114
src/boss_engine/mod.rs Normal file
View File

@@ -0,0 +1,114 @@
pub mod copy;
use std::path::{Path, PathBuf};
use crate::error::{CliError, Result};
/// 发现项目布局: BP/RP 目录 + scripts package 目录。
/// 逻辑与 AbyssBossEngine tests/conftest.py 的自动发现一致。
pub fn discover_layout(project_dir: &Path) -> Result<Layout> {
let behavior_pack = find_single_dir(project_dir, "behavior_pack_")?;
let resource_pack = find_single_dir(project_dir, "resource_pack_")?;
let scripts_package = find_scripts_package(&behavior_pack)?;
let scripts_package_name = scripts_package
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| {
CliError::InvalidData(format!(
"scripts package 目录名非 UTF-8: '{}'",
scripts_package.display()
))
})?
.to_string();
Ok(Layout {
behavior_pack,
resource_pack,
scripts_package,
scripts_package_name,
})
}
pub struct Layout {
pub behavior_pack: PathBuf,
pub resource_pack: PathBuf,
pub scripts_package: PathBuf,
pub scripts_package_name: String,
}
fn find_single_dir(parent: &Path, prefix: &str) -> Result<PathBuf> {
let mut matches = Vec::new();
for entry in std::fs::read_dir(parent)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with(prefix) {
matches.push(path);
}
}
}
}
match matches.len() {
0 => Err(CliError::NotFound(format!(
"在 '{}' 下未找到 {}* 目录",
parent.display(),
prefix
))),
1 => Ok(matches.into_iter().next().unwrap()),
_ => Err(CliError::InvalidData(format!(
"在 '{}' 下找到多个 {}* 目录",
parent.display(),
prefix
))),
}
}
fn find_scripts_package(behavior_pack: &Path) -> Result<PathBuf> {
let mut matches = Vec::new();
for entry in std::fs::read_dir(behavior_pack)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("modMain.py").is_file() {
matches.push(path);
}
}
match matches.len() {
0 => Err(CliError::NotFound(format!(
"在 '{}' 下未找到含 modMain.py 的 scripts package",
behavior_pack.display()
))),
1 => Ok(matches.into_iter().next().unwrap()),
_ => Err(CliError::InvalidData(format!(
"在 '{}' 下找到多个含 modMain.py 的目录",
behavior_pack.display()
))),
}
}
/// PascalCase → snake_case: AbyssBossEngine → abyss_boss_engine
pub fn pascal_to_snake(s: &str) -> String {
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.extend(ch.to_lowercase());
}
result
}
/// 首字母小写: AbyssBossEngine → abyssBossEngine
pub fn lower_first_char(s: &str) -> Result<String> {
let mut chars = s.chars();
let first = chars
.next()
.ok_or_else(|| CliError::InvalidInput("name 不能为空".to_string()))?;
Ok(format!(
"{}{}",
first.to_lowercase().collect::<String>(),
chars.as_str()
))
}