use std::path::Path;
use std::process::{Command, Stdio};
use super::output_util;
use crate::cm_tools::tools::tool_param_types::PreCommitRunArgs;
const MAX_OUTPUT_LINES: usize = 800;
fn has_precommit_config(root: &Path) -> bool {
root.join(".pre-commit-config.yaml").is_file() || root.join(".pre-commit-config.yml").is_file()
}
fn parse_pre_commit_json(args_json: &str) -> Result<(PreCommitRunArgs, serde_json::Value), String> {
let parsed = crate::cm_tools::tools::parse_args_json(args_json)?;
let args: PreCommitRunArgs =
serde_json::from_value(parsed).map_err(|e| format!("参数解析错误: {e}"))?;
let v = serde_json::to_value(&args).map_err(|e| format!("参数序列化错误: {e}"))?;
Ok((args, v))
}
fn validate_pre_commit_hook_id(args: &PreCommitRunArgs) -> Result<(), String> {
if let Some(h) = args.hook.as_deref().map(str::trim)
&& !h.is_empty()
&& !is_safe_hook_id(h)
{
return Err("错误:hook 仅允许字母数字与 ._-,且须以字母或数字开头".to_string());
}
Ok(())
}
fn apply_pre_commit_cli_flags(cmd: &mut Command, v: &serde_json::Value, files: &[String]) {
if let Some(h) = v.get("hook").and_then(|x| x.as_str()).map(str::trim)
&& !h.is_empty()
{
cmd.arg(h);
}
if v.get("verbose").and_then(|x| x.as_bool()).unwrap_or(false) {
cmd.arg("--verbose");
}
let all_files = v
.get("all_files")
.and_then(|x| x.as_bool())
.unwrap_or(false);
if !files.is_empty() {
cmd.arg("--files");
for p in files {
cmd.arg(p);
}
} else if all_files {
cmd.arg("--all-files");
}
}
pub fn pre_commit_run(args_json: &str, workspace_root: &Path, max_output_len: usize) -> String {
if !has_precommit_config(workspace_root) {
return "pre-commit run: 跳过(未找到 .pre-commit-config.yaml / .pre-commit-config.yml)"
.to_string();
}
let (args, v) = match parse_pre_commit_json(args_json) {
Ok(x) => x,
Err(e) => return e,
};
if let Err(e) = validate_pre_commit_hook_id(&args) {
return e;
}
let files = match parse_files_array(&v) {
Ok(f) => f,
Err(e) => return e,
};
let base = match workspace_root.canonicalize() {
Ok(p) => p,
Err(e) => return format!("工作区根目录无法解析: {}", e),
};
let mut cmd = Command::new("pre-commit");
cmd.arg("run").current_dir(&base);
apply_pre_commit_cli_flags(&mut cmd, &v, &files);
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
output_util::run_command_output_formatted(
cmd,
"pre-commit run",
max_output_len,
MAX_OUTPUT_LINES,
output_util::ProcessOutputMerge::StderrElseStdout,
output_util::CommandSpawnErrorStyle::CannotStartCommand,
)
}
fn is_safe_hook_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 128
&& s.chars().next().is_some_and(|c| c.is_ascii_alphanumeric())
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn parse_files_array(v: &serde_json::Value) -> Result<Vec<String>, String> {
let Some(raw) = v.get("files") else {
return Ok(Vec::new());
};
let Some(arr) = raw.as_array() else {
return Err("错误:files 须为字符串数组".to_string());
};
let mut out = Vec::new();
for x in arr {
let s = x
.as_str()
.map(str::trim)
.filter(|t| !t.is_empty())
.ok_or_else(|| "错误:files 项须为非空字符串".to_string())?;
if s.starts_with('/') || s.contains("..") {
return Err(format!(
"错误:files 项须为工作区内相对路径且不含 ..:{}",
s
));
}
out.push(s.to_string());
}
Ok(out)
}