feat(cli): 优化 log 子命令并引入 LogLevel 强类型解析,重构 commands.lua 注册表解析与校验逻辑

- 调整 LogLevel 的 FromStr 错误类型为 String,适配 clap 的 value_parser
- log 子命令直接绑定 LogLevel 枚举,消除硬编码校验与类型转换
- 规范化日志级别更新与配置读取逻辑
- handler 类型有 Function 改为 String
This commit is contained in:
2026-09-04 20:20:22 +08:00
parent 0c478ae39d
commit 941558cb92
29 changed files with 2238 additions and 47 deletions

18
mirror-cli/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "mirror-cli"
version = { workspace = true }
edition = { workspace = true }
# 显式指定生成的二进制文件名
[[bin]]
name = "mr"
path = "src/main.rs"
[dependencies]
mirror-core = { path = "../mirror-core" }
clap = { version = "4.6.6", features = ["cargo", "color", "derive","string"] }
clap_derive = { version = "4.6.4" }
mlua = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }

35
mirror-cli/src/app.rs Normal file
View File

@@ -0,0 +1,35 @@
use crate::cli;
use crate::commands::{builtin, dynamic::MrCommand};
use anyhow::{Context, Result, bail};
use mirror_core::{Layout, LuaRuntime};
pub fn run() -> Result<()> {
let exe = std::env::current_exe().context("获取代理程序路径失败")?;
let layout = Layout::from(&exe)?;
let registry_file = layout.base_dir.join("commands").join("commands.lua");
let runtime = LuaRuntime::new(&layout)?;
let commands: MrCommand = runtime
.eval_script(&registry_file)
.with_context(|| format!("加载命令配置失败: {}", registry_file.display()))?;
let app = cli::build(commands.clone());
let matches = app.get_matches();
match matches.subcommand() {
// 分支 A: 原生 Rust 实现的命令
Some(("log", sub_m)) => builtin::log_handle(&layout, sub_m),
// 分支 B: 动态 Lua 注册的命令
Some((cmd_name, sub_m)) => {
let sub_cmd = commands
.get(cmd_name)
.with_context(|| format!("未找到子命令 '{cmd_name}'"))?;
let raw_args = cli::extract_raw_args(sub_m);
runtime.require_and_run(&sub_cmd.module, raw_args)
}
// 分支 C: 未输入子命令(通常已被 Clap 的 arg_required_else_help 拦截)
None => Ok(()),
}
}

62
mirror-cli/src/cli.rs Normal file
View File

@@ -0,0 +1,62 @@
use crate::commands::dynamic::MrCommand;
use clap::{Arg, ArgAction, Command};
use mirror_core::LogLevel;
use std::ffi::OsString;
// 引入强类型枚举
pub fn build(dynamic_cmds: MrCommand) -> Command {
let mut app = Command::new("mr")
.about("Mirror CLI Manager")
.version("0.1.0")
.arg_required_else_help(true)
.subcommand(log_subcommand());
for (name, sub_cmd) in dynamic_cmds.commands {
if name == "log" {
continue;
}
// let a =name.as_str()
// 开启 "string" feature 后String 可以直接作为 Command 的入参
app = app.subcommand(
Command::new(name)
.about(sub_cmd.description)
.allow_external_subcommands(true),
);
}
app
}
/// 构建基础 CLI 骨架(内置 Rust 原生命令与参数规则)
fn log_subcommand() -> Command {
Command::new("log")
.about("查看或修改 mirror.ini 日志配置")
.arg(
Arg::new("tail")
.short('t')
.long("tail")
.value_name("LINES")
.help("显示最近的 N 行日志")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new("level")
.short('l')
.long("level")
.value_name("LEVEL")
.help("设置运行时日志级别")
.value_parser(clap::value_parser!(LogLevel)),
)
.arg(
Arg::new("follow")
.short('f')
.long("follow")
.help("持续跟踪日志输出 (tail -f)")
.action(ArgAction::SetTrue),
)
}
pub fn extract_raw_args(matches: &clap::ArgMatches) -> Vec<String> {
matches
.get_many::<OsString>("")
.unwrap_or_default()
.map(|s| s.to_string_lossy().into_owned())
.collect()
}

View File

@@ -0,0 +1,35 @@
use anyhow::{Context, Result};
use mirror_core::Layout;
use std::fs;
// Rust 原生处理 log 命令逻辑
pub(crate) fn log_handle(layout: &Layout, matches: &clap::ArgMatches) -> Result<()> {
let log_ini_path = layout.base_dir.join("mirror-log.ini");
// 1. 处理设置日志等级
if let Some(level) = matches.get_one::<String>("level") {
let content = format!("level = \"{}\"\nlog_dir = \"logs\"\n", level.as_str());
fs::write(&log_ini_path, content)
.with_context(|| format!("写入日志配置文件失败: {}", log_ini_path.display()))?;
println!("日志级别已更新为: {level} ({})", log_ini_path.display());
return Ok(());
}
// 2. 处理 tail 查看
if let Some(&tail_lines) = matches.get_one::<usize>("tail") {
println!("正在检索最后 {tail_lines} 行日志...");
// 原生 Rust 高性能按行倒序读取日志文件
return Ok(());
}
// 3. 默认打印当前配置
if log_ini_path.is_file() {
let current_ini = fs::read_to_string(&log_ini_path)
.with_context(|| format!("读取配置文件失败: {}", log_ini_path.display()))?;
println!("当前 mirror-log.ini 配置:\n{current_ini}");
} else {
println!("未找到 mirror-log.ini当前使用默认全局级别: info");
}
Ok(())
}

View File

@@ -0,0 +1,139 @@
use crate::validators;
use mirror_core::error::validation_error;
use mlua::{FromLua, Lua, Value};
use std::collections::HashMap;
const ALLOWED_KEYS: &[&str] = &["handler", "description"];
#[derive(Debug, Default, Clone)]
pub struct MrCommand {
pub commands: HashMap<String, MrSubCommand>,
}
impl MrCommand {
pub fn get(&self, name: &str) -> Option<&MrSubCommand> {
self.commands.get(name)
}
}
impl FromLua for MrCommand {
fn from_lua(value: Value, lua: &Lua) -> mlua::Result<Self> {
let root_tbl = match value {
Value::Table(t) => t,
other => {
return Err(validation_error(format!(
"commands 注册表的顶层配置必须是 Table实际检测到: {}",
other.type_name()
)));
}
};
let mut commands = HashMap::new();
for pair in root_tbl.pairs::<Value, Value>() {
let (cmd_name, cmd_entry) = pair?;
let cmd_name = validators::validate_command_name(&cmd_name)?;
// 2. 校验 entry 是否是 Table
if !cmd_entry.is_table() {
return Err(validation_error(format!(
"子命令 '{cmd_name}' 的配置必须是 Table实际检测到: {}",
cmd_entry.type_name()
)));
}
// 3. 直接交由子命令解析,外层负责补充错误上下文
let cmd_def = MrSubCommand::from_lua(cmd_entry, lua).map_err(|err| {
validation_error(format!("子命令 '{cmd_name}' 配置解析失败:\n{err}"))
})?;
commands.insert(cmd_name, cmd_def);
}
Ok(Self { commands })
}
}
#[derive(Debug, Default, Clone)]
pub struct MrSubCommand {
/// 命令处理函数:必须是 Lua Function
pub(crate) module: String,
/// 命令行说明:必须是普通文本字符串
pub(crate) description: String,
}
impl FromLua for MrSubCommand {
fn from_lua(value: Value, _lua: &Lua) -> mlua::Result<Self> {
let table = match value {
Value::Table(t) => t,
other => {
return Err(validation_error(format!(
"配置必须是 Table实际检测到: {}",
other.type_name()
)));
}
};
// validators::LuaValidator::parse_command_entry(&tbl)
for pair in table.pairs::<Value, Value>() {
let (key, _) = pair?;
let key_str = validators::validate_command_name(&key)?;
if !ALLOWED_KEYS.contains(&key_str.as_str()) {
return Err(validation_error(format!(
"包含未知字段 '{key_str}'。\n\
==> 合法字段仅支持: {}\n\
==> 请检查是否存在拼写手误(例如把 handler 写成了 handle/handlr",
ALLOWED_KEYS.join(", ")
)));
}
}
let handler_val = table.get::<Value>("handler")?;
// let handler_func = match handler_val {
// Value::Function(f) => f,
// Value::Boolean(true) => {
// return Err(validation_error(
// "子命令 handler 返回了布尔值 true。\n\
// ==>排查提示:脚本已成功加载,但未返回函数。\n\
// ==>请在末尾加上 'return function(args) ... end'。",
// ));
// }
// Value::Nil => {
// return Err(validation_error(
// "子命令缺少必填字段 handler或对应脚本未返回函数",
// ));
// }
// other => {
// return Err(validation_error(format!(
// "'handler' 必须是函数 (function),实际类型是: {}",
// other.type_name()
// )));
// }
// };
let module = match handler_val {
Value::Table(t) if t.get::<bool>("__is_lazy_command").unwrap_or(false) => {
t.get::<String>("module")?
}
Value::String(s) => s.to_str()?.to_string(), // 同时兼容直接写字符串的情况
other => {
return Err(mlua::Error::runtime(format!(
"handler 必须通过 command(\"xxx\") 声明,实际为 {}",
other.type_name()
)));
}
};
// 可选字段: args缺失或 nil 时默认空列表;保留空字符串参数,与提权路径的 "" 语义一致)
let description_val = table.get::<Value>("description")?;
let description = match description_val {
Value::Nil => String::new(),
Value::String(s) => s.to_str()?.to_string(),
other => {
return Err(validation_error(format!(
"必须是字符串文本,实际类型是 {}",
other.type_name()
)));
}
};
// let handler = lua.create_registry_value(handler_func)?;
Ok(Self {
module,
description,
})
}
}

View File

@@ -0,0 +1,2 @@
pub(crate) mod builtin;
pub(crate) mod dynamic;

14
mirror-cli/src/main.rs Normal file
View File

@@ -0,0 +1,14 @@
mod app;
mod cli;
mod commands;
mod validators;
use std::process::ExitCode;
fn main() -> ExitCode {
if let Err(err) = app::run() {
eprintln!("\x1b[31m[mr error]\x1b[0m {:#}", err);
return ExitCode::FAILURE; // 返回退出码 1
}
ExitCode::SUCCESS // 返回退出码 0
}

View File

@@ -0,0 +1,41 @@
use mirror_core::error::validation_error;
use mlua::Value;
pub fn validate_command_name(name: &Value) -> mlua::Result<String> {
let name_str = match name {
Value::String(s) => s
.to_str()
.map_err(|_| validation_error("子命令名称必须是合法的 UTF-8 字符串"))?
.to_string(),
other => {
return Err(validation_error(format!(
"命令行键名类型错误:期望 string实际是 {}",
other.type_name()
)));
}
};
let trimmed = name_str.trim();
if trimmed.is_empty() {
return Err(validation_error("子命令名称不能为空字符串"));
}
// 2. 禁止包含空格与不可见控制符(否则 shell 与 clap 无法正确定位)
if name_str.contains(|c: char| c.is_whitespace()) {
return Err(validation_error(format!(
"子命令名称 [{name_str}] 非法:命令名不能包含空格或空白字符"
)));
}
if name_str.contains('=') {
return Err(validation_error(format!(
"子命令名称 [{}] 不能包含 '='",
name_str
)));
}
if name_str.contains('\0') {
return Err(validation_error(format!(
"子命令名称 [{}] 不能包含 NUL 字符",
name_str
)));
}
Ok(name_str)
}