feat(boss-engine): 增加项目派生命令
新增 fork-boss-engine,从 AbyssBossEngine 源项目生成独立 BOSS 模组,并重建 UUID、命名空间和脚本包。 校验项目名称与命名空间,同时阻止输出目录落入源目录,避免路径穿越和递归复制。
This commit is contained in:
35
src/boss_engine/copy.rs
Normal file
35
src/boss_engine/copy.rs
Normal 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
114
src/boss_engine/mod.rs
Normal 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()
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user