feat(glossary): 实现 Rust Glossary V1

This commit is contained in:
2026-09-07 22:38:53 +08:00
parent 8fc93b8f39
commit 94483ff14d
42 changed files with 4543 additions and 92 deletions
@@ -0,0 +1,649 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::{
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
use std::collections::BTreeMap;
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
let method = glossary_method(options.command)?;
if daemon_rpc_available(&options.state_dir)
&& options.resource_root.is_none()
&& !options.output_explicit
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let path = glossary_cli_path(options)?;
let report =
match options.command {
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
CliCommand::GlossaryQuery => build_glossary_query_report(
&path,
options.glossary_source_text.as_deref(),
options.glossary_category.as_deref(),
options.glossary_review_status.as_deref(),
options.query_limit,
)?,
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
&path,
options.glossary_source_text.as_deref().unwrap_or_default(),
parse_glossary_context(options.glossary_context_json.as_deref())?,
)?,
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
let reviewer = options.glossary_reviewer.as_deref();
build_glossary_mutation_report(
&path,
&draft,
options.command == CliCommand::GlossaryUpdate,
reviewer,
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
})?;
let status = if options.command == CliCommand::GlossaryApprove {
GlossaryReviewStatus::Approved
} else {
GlossaryReviewStatus::Deprecated
};
build_glossary_review_report(
&path,
term_id,
status,
reviewer,
options.glossary_reason.clone(),
)?
}
_ => unreachable!(),
};
print_json_value(options.output_format, &report)
}
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
Ok(match command {
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
})
}
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
if let Some(path) = options.glossary_path.as_ref() {
return lexical_absolute(path).map_err(anyhow::Error::msg);
}
let resource_root = options
.resource_root
.as_deref()
.map(lexical_absolute)
.transpose()
.map_err(anyhow::Error::msg)?
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
}
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
let mut params = serde_json::Map::new();
if let Some(path) = options.glossary_path.as_ref() {
params.insert("glossary_path".to_string(), serde_json::json!(path));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if let Some(source_text) = options.glossary_source_text.as_deref() {
params.insert("source_text".to_string(), serde_json::json!(source_text));
}
if let Some(category) = options.glossary_category.as_deref() {
params.insert("category".to_string(), serde_json::json!(category));
}
if let Some(status) = options.glossary_review_status.as_deref() {
params.insert("review_status".to_string(), serde_json::json!(status));
}
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::GlossaryDiagnose => {
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
})?;
params.insert("source_text".to_string(), serde_json::json!(source_text));
params.insert(
"context".to_string(),
serde_json::json!(parse_glossary_context(
options.glossary_context_json.as_deref()
)?),
);
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
params.extend(
serde_json::to_value(draft)?
.as_object()
.cloned()
.unwrap_or_default(),
);
if options.command == CliCommand::GlossaryUpdate {
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
}
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
let source_term = required_option(
options.glossary_source_term.as_deref(),
"--glossary-source-term",
)?;
let recommended_translation = required_option(
options.glossary_recommended_translation.as_deref(),
"--glossary-recommended-translation",
)?;
let aliases = parse_string_array(
options.glossary_aliases_json.as_deref(),
"--glossary-aliases-json",
)?;
let allowed_translations = parse_string_array(
options.glossary_allowed_translations_json.as_deref(),
"--glossary-allowed-translations-json",
)?;
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
let source_kind = GlossarySourceKind::parse(source_kind)
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
let review_status = GlossaryReviewStatus::parse(review_status)
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
let now = unix_seconds_now();
Ok(GlossaryTermDraft {
term_id,
definition: GlossaryTermSnapshot {
source_term,
aliases,
recommended_translation,
allowed_translations,
source_language: options.glossary_source_language.clone(),
target_language: options.glossary_target_language.clone(),
category: options.glossary_category.clone(),
priority: options.glossary_priority,
scope,
},
review_status,
source: GlossarySourceRecord {
source_kind,
source_ref: options.glossary_source_ref.clone(),
source_author: options.glossary_source_author.clone(),
source_note: options.glossary_source_note.clone(),
observed_unix_seconds: now,
},
})
}
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
}
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
let Some(value) = value else {
return Ok(Vec::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array{error}"))
}
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object{error}"))
}
pub(super) fn build_glossary_summary_report(
path: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let summary = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.summary()
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"summary": summary,
}))
}
pub(super) fn build_glossary_query_report(
path: &std::path::Path,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<&str>,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let status = review_status
.map(|value| {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
})
.transpose()?;
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"terms": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let terms = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.query(source_text, category, status, limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"terms": terms,
}))
}
pub(super) fn build_glossary_diagnose_report(
path: &std::path::Path,
source_text: &str,
context: TranslationMemoryContext,
) -> anyhow::Result<serde_json::Value> {
if source_text.trim().is_empty() {
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
}
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": {
"constraints": [],
"diagnostics": [],
"blocked": false
},
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let evaluation = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.diagnose(source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": evaluation,
}))
}
pub(super) fn build_glossary_mutation_report(
path: &std::path::Path,
draft: &GlossaryTermDraft,
update: bool,
reviewer: Option<&str>,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::new(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let result = if update {
repository
.update(
draft.clone(),
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
reason,
)
.await
} else {
repository.add(draft.clone()).await
};
result.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"term": term,
}))
}
pub(super) fn build_glossary_review_report(
path: &std::path::Path,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.review(term_id, status, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(request_id, error),
}
}
pub(super) fn glossary_summary_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
params,
RPC_METHOD_GLOSSARY_SUMMARY,
)?;
build_glossary_summary_report(&path)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
}
pub(super) fn glossary_query_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_QUERY)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
let category = glossary_string(&params, "category", RPC_METHOD_GLOSSARY_QUERY)?;
let review_status = glossary_string(&params, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
let limit = glossary_limit(&params, RPC_METHOD_GLOSSARY_QUERY)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_QUERY,
)?;
build_glossary_query_report(&path, source_text, category, review_status, limit)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
}
pub(super) fn glossary_diagnose_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
let context = glossary_context(&params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DIAGNOSE,
)?;
build_glossary_diagnose_report(&path, source_text, context)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
}
pub(super) fn glossary_mutation_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
update: bool,
) -> Result<serde_json::Value, ApiError> {
let method = if update {
RPC_METHOD_GLOSSARY_UPDATE
} else {
RPC_METHOD_GLOSSARY_ADD
};
let params = glossary_params(params, method)?;
let draft: GlossaryTermDraft =
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
})?;
let reviewer = glossary_string(&params, "reviewer", method)?;
if update && reviewer.is_none() {
return Err(glossary_invalid(method, "update 缺少 reviewer"));
}
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_review_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
status: GlossaryReviewStatus,
) -> Result<serde_json::Value, ApiError> {
let method = if status == GlossaryReviewStatus::Approved {
RPC_METHOD_GLOSSARY_APPROVE
} else {
RPC_METHOD_GLOSSARY_DEPRECATE
};
let params = glossary_params(params, method)?;
let term_id = glossary_string(&params, "term_id", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_review_report(&path, term_id, status, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
fn glossary_rpc_path(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<std::path::PathBuf, ApiError> {
if let Some(path) = params
.and_then(|value| value.get("glossary_path"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return lexical_absolute(Path::new(path))
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
if let Some(path) = default_path {
return lexical_absolute(path)
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
let (_, version_state) = read_daemon_resource_state(state_dir)
.map_err(|error| glossary_internal_error(method, error))?;
if let Some(record) = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
{
return Ok(SqliteGlossaryRepository::repository_path(
&record.resource_root,
));
}
Ok(SqliteGlossaryRepository::repository_path(output_root))
}
fn glossary_params(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
match params {
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
}
}
fn glossary_string<'a>(
params: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
method: &'static str,
) -> Result<Option<&'a str>, ApiError> {
let Some(value) = params.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
.map(Some)
}
fn glossary_context(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<TranslationMemoryContext, ApiError> {
let Some(value) = params
.get("context")
.or_else(|| params.get("source_context"))
else {
return Ok(BTreeMap::new());
};
serde_json::from_value(value.clone()).map_err(|error| {
glossary_invalid(method, format!("context 必须是 JSON string map{error}"))
})
}
fn glossary_limit(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<usize, ApiError> {
let limit = params
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(100);
let limit = usize::try_from(limit)
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
if !(1..=1000).contains(&limit) {
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
}
Ok(limit)
}
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
}
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
}