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
+757
View File
@@ -0,0 +1,757 @@
//! Project-level Glossary V1 SQLite repository.
use crate::path_security::{
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
};
use async_trait::async_trait;
use bat_core::domain::{
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_core::repositories::GlossaryRepository;
use bat_core::{Error, Result};
use serde::de::DeserializeOwned;
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
use sqlx::{Row, SqlitePool};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
/// Glossary SQLite schema version.
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
/// Schema migration component.
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
/// Default project-level glossary file.
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
/// SQLite-backed project Glossary repository.
#[derive(Debug, Clone)]
pub struct SqliteGlossaryRepository {
pool: SqlitePool,
}
impl SqliteGlossaryRepository {
/// Creates or opens a glossary database.
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), true).await
}
/// Opens an existing glossary database without creating it.
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
Self::open_with(path.as_ref(), false).await
}
/// Returns the project-level glossary path for an official release root.
pub fn repository_path(resource_root: &Path) -> PathBuf {
if resource_root
.parent()
.and_then(Path::file_name)
.is_some_and(|name| name == "versions")
{
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
return output_root.join(GLOSSARY_REPOSITORY_FILE);
}
}
resource_root.join(GLOSSARY_REPOSITORY_FILE)
}
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
let parent = absolute.parent().ok_or_else(|| {
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
})?;
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
if create_if_missing {
tokio::fs::create_dir_all(parent).await?;
ensure_safe_directory_path(parent, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
} else if !absolute.is_file() {
return Err(Error::NotFound(absolute.display().to_string()));
}
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(Error::InvalidArgument(format!(
"Glossary 数据库必须是普通文件:{}",
absolute.display()
)));
}
}
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
.map_err(|error| Error::Other(error.into()))?
.create_if_missing(create_if_missing)
.journal_mode(SqliteJournalMode::Wal)
.busy_timeout(Duration::from_secs(30));
let pool = SqlitePoolOptions::new()
.max_connections(1)
.connect_with(options)
.await
.map_err(db_error)?;
if create_if_missing {
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
.map_err(Error::InvalidArgument)?;
}
let repository = Self { pool };
repository.init_schema().await?;
Ok(repository)
}
async fn init_schema(&self) -> Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS schema_migrations (
component TEXT PRIMARY KEY NOT NULL,
version INTEGER NOT NULL CHECK(version >= 1)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_terms (
term_id TEXT PRIMARY KEY NOT NULL,
source_term TEXT NOT NULL,
aliases_json TEXT NOT NULL,
recommended_translation TEXT NOT NULL,
allowed_translations_json TEXT NOT NULL,
source_language TEXT,
target_language TEXT,
category TEXT,
priority INTEGER NOT NULL,
scope_json TEXT NOT NULL,
review_status TEXT NOT NULL,
source_kind TEXT NOT NULL,
source_ref TEXT,
source_author TEXT,
source_note TEXT,
source_observed_unix_seconds INTEGER NOT NULL,
created_unix_seconds INTEGER NOT NULL,
updated_unix_seconds INTEGER NOT NULL,
CHECK(length(term_id) > 0),
CHECK(length(source_term) > 0),
CHECK(length(recommended_translation) > 0),
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
CHECK(source_kind IN ('manual', 'imported'))
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
ensure_column(
&self.pool,
"glossary_terms",
"source_observed_unix_seconds",
"INTEGER NOT NULL DEFAULT 1",
)
.await?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_history (
history_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
action TEXT NOT NULL,
reviewer TEXT,
reason TEXT,
source_json TEXT NOT NULL,
review_status TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL,
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
ON glossary_terms(source_term)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
let current: Option<i64> =
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
.bind(GLOSSARY_SCHEMA_COMPONENT)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?;
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
return Err(Error::InvalidArgument(format!(
"不支持的 Glossary schema 版本:{}",
current.unwrap_or_default()
)));
}
sqlx::query(
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
)
.bind(GLOSSARY_SCHEMA_COMPONENT)
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
.execute(&self.pool)
.await
.map_err(db_error)?;
Ok(())
}
/// Returns one term with complete history.
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
let row = sqlx::query(
"SELECT term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
FROM glossary_terms WHERE term_id = ?1",
)
.bind(term_id)
.fetch_optional(&self.pool)
.await
.map_err(db_error)?
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
let mut term = row_to_term(row)?;
let history_rows = sqlx::query(
"SELECT history_id, action, reviewer, reason, source_json, review_status,
snapshot_json, observed_unix_seconds
FROM glossary_term_history WHERE term_id = ?1
ORDER BY observed_unix_seconds ASC, history_id ASC",
)
.bind(term_id)
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
term.history = history_rows
.into_iter()
.map(row_to_history)
.collect::<Result<Vec<_>>>()?;
Ok(term)
}
/// Queries terms, including non-approved terms for review.
pub async fn query(
&self,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
limit: usize,
) -> Result<Vec<GlossaryTerm>> {
if !(1..=1000).contains(&limit) {
return Err(Error::InvalidArgument(
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
));
}
let rows = sqlx::query(
"SELECT term_id FROM glossary_terms
WHERE (?1 IS NULL OR category = ?1)
AND (?2 IS NULL OR review_status = ?2)
ORDER BY priority DESC, term_id ASC",
)
.bind(category)
.bind(review_status.map(|value| value.as_str()))
.fetch_all(&self.pool)
.await
.map_err(db_error)?;
let mut terms = Vec::new();
for row in rows {
let term_id: String = row.try_get("term_id").map_err(db_error)?;
let term = self.find(&term_id).await?;
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
Ok(terms)
}
/// Returns review-state counts.
pub async fn summary(&self) -> Result<GlossarySummary> {
let row = sqlx::query(
"SELECT COUNT(*) AS term_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
FROM glossary_terms",
)
.fetch_one(&self.pool)
.await
.map_err(db_error)?;
Ok(GlossarySummary {
schema_version: GLOSSARY_SCHEMA_VERSION,
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
deprecated_count: row
.try_get::<i64, _>("deprecated_count")
.map_err(db_error)? as u64,
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
})
}
/// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, now, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let existing: Option<String> =
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
.bind(&draft.term_id)
.fetch_optional(&mut *transaction)
.await
.map_err(db_error)?;
if existing.is_some() {
return Err(Error::InvalidArgument(format!(
"Glossary term_id 已存在:{}",
draft.term_id
)));
}
insert_term(&mut transaction, &term).await?;
insert_history(&mut transaction, &term, "created", None, None, now).await?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Replaces a term definition and records the previous source history.
pub async fn update(
&self,
draft: GlossaryTermDraft,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?;
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary update reviewer 不能为空".to_string(),
));
}
let current = self.find(&draft.term_id).await?;
let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, current.created_unix_seconds, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?;
update_term(&mut transaction, &term).await?;
insert_history(
&mut transaction,
&term,
"updated",
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(&draft.term_id).await
}
/// Changes review state and records a source/review history entry.
pub async fn review(
&self,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary reviewer 不能为空".to_string(),
));
}
if !matches!(
status,
GlossaryReviewStatus::Approved
| GlossaryReviewStatus::Deprecated
| GlossaryReviewStatus::Rejected
) {
return Err(Error::InvalidArgument(
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
));
}
let mut term = self.find(term_id).await?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
term.review_status = status;
term.updated_unix_seconds = now;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
sqlx::query(
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
WHERE term_id = ?1",
)
.bind(term_id)
.bind(status.as_str())
.bind(i64::try_from(now).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
insert_history(
&mut transaction,
&term,
status.as_str(),
Some(reviewer.trim()),
reason.as_deref(),
now,
)
.await?;
transaction.commit().await.map_err(db_error)?;
self.find(term_id).await
}
/// Evaluates approved terms for one TextUnit.
pub async fn diagnose(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
let terms = self
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
.await?;
Ok(evaluate_glossary(&terms, source_text, context))
}
}
#[async_trait]
impl GlossaryRepository for SqliteGlossaryRepository {
async fn evaluate(
&self,
source_text: &str,
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
self.diagnose(source_text, context).await
}
}
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
GlossaryTerm {
term_id: draft.term_id.clone(),
definition: draft.definition.clone(),
review_status: draft.review_status,
source: draft.source.clone(),
history: Vec::new(),
created_unix_seconds: created,
updated_unix_seconds: updated,
}
}
async fn insert_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"INSERT INTO glossary_terms (
term_id, source_term, aliases_json, recommended_translation,
allowed_translations_json, source_language, target_language, category,
priority, scope_json, review_status, source_kind, source_ref,
source_author, source_note, source_observed_unix_seconds,
created_unix_seconds, updated_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn update_term(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
) -> Result<()> {
sqlx::query(
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
recommended_translation = ?4, allowed_translations_json = ?5,
source_language = ?6, target_language = ?7, category = ?8,
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
source_ref = ?13, source_author = ?14, source_note = ?15,
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
)
.bind(&term.term_id)
.bind(&term.definition.source_term)
.bind(json(&term.definition.aliases)?)
.bind(&term.definition.recommended_translation)
.bind(json(&term.definition.allowed_translations)?)
.bind(&term.definition.source_language)
.bind(&term.definition.target_language)
.bind(&term.definition.category)
.bind(term.definition.priority)
.bind(json(&term.definition.scope)?)
.bind(term.review_status.as_str())
.bind(term.source.source_kind.as_str())
.bind(&term.source.source_ref)
.bind(&term.source.source_author)
.bind(&term.source.source_note)
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
async fn insert_history(
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
term: &GlossaryTerm,
action: &str,
reviewer: Option<&str>,
reason: Option<&str>,
observed: u64,
) -> Result<()> {
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let mut history_hasher = blake3::Hasher::new();
for value in [
term.term_id.as_str(),
action,
reviewer.unwrap_or_default(),
reason.unwrap_or_default(),
source_json.as_str(),
snapshot_json.as_str(),
] {
history_hasher.update(value.as_bytes());
history_hasher.update(&[0]);
}
history_hasher.update(&observed.to_le_bytes());
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
history_hasher.update(&nonce.to_le_bytes());
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
sqlx::query(
"INSERT INTO glossary_term_history (
history_id, term_id, action, reviewer, reason, source_json,
review_status, snapshot_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.bind(history_id)
.bind(&term.term_id)
.bind(action)
.bind(reviewer)
.bind(reason.filter(|value| !value.trim().is_empty()))
.bind(source_json)
.bind(term.review_status.as_str())
.bind(snapshot_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut **transaction)
.await
.map_err(db_error)?;
Ok(())
}
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
Ok(GlossaryTerm {
term_id: row.try_get("term_id").map_err(db_error)?,
definition: GlossaryTermSnapshot {
source_term: row.try_get("source_term").map_err(db_error)?,
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
allowed_translations: parse_json(
row.try_get("allowed_translations_json").map_err(db_error)?,
)?,
source_language: row.try_get("source_language").map_err(db_error)?,
target_language: row.try_get("target_language").map_err(db_error)?,
category: row.try_get("category").map_err(db_error)?,
priority: row.try_get("priority").map_err(db_error)?,
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
},
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
source: GlossarySourceRecord {
source_kind: parse_source_kind(
row.try_get::<String, _>("source_kind")
.map_err(db_error)?
.as_str(),
)?,
source_ref: row.try_get("source_ref").map_err(db_error)?,
source_author: row.try_get("source_author").map_err(db_error)?,
source_note: row.try_get("source_note").map_err(db_error)?,
observed_unix_seconds: to_u64(
row.try_get("source_observed_unix_seconds")
.map_err(db_error)?,
"source",
)?,
},
history: Vec::new(),
created_unix_seconds: to_u64(
row.try_get("created_unix_seconds").map_err(db_error)?,
"created",
)?,
updated_unix_seconds: to_u64(
row.try_get("updated_unix_seconds").map_err(db_error)?,
"updated",
)?,
})
}
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
Ok(GlossaryHistoryRecord {
history_id: row.try_get("history_id").map_err(db_error)?,
action: row.try_get("action").map_err(db_error)?,
reviewer: row.try_get("reviewer").map_err(db_error)?,
reason: row.try_get("reason").map_err(db_error)?,
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
review_status: parse_review_status(
row.try_get::<String, _>("review_status")
.map_err(db_error)?
.as_str(),
)?,
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
observed_unix_seconds: to_u64(
row.try_get("observed_unix_seconds").map_err(db_error)?,
"history",
)?,
})
}
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
}
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
}
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status{value}")))
}
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
GlossarySourceKind::parse(value)
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind{value}")))
}
fn to_u64(value: i64, label: &str) -> Result<u64> {
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
}
fn db_error(error: sqlx::Error) -> Error {
Error::Other(error.into())
}
async fn ensure_column(
pool: &SqlitePool,
table: &str,
column: &str,
definition: &str,
) -> Result<()> {
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(pool)
.await
.map_err(db_error)?;
let exists = columns.iter().any(|row| {
row.try_get::<String, _>("name")
.map(|name| name == column)
.unwrap_or(false)
});
if !exists {
sqlx::query(&format!(
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
))
.execute(pool)
.await
.map_err(db_error)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: vec!["老师大人".to_string()],
source_language: Some("en".to_string()),
target_language: Some("zh-Hans".to_string()),
category: Some("person".to_string()),
priority: 10,
scope: BTreeMap::new(),
},
review_status: status,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 1,
},
}
}
#[tokio::test]
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
let temp = tempfile::TempDir::new().unwrap();
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
.await
.unwrap();
repository
.add(draft(GlossaryReviewStatus::Draft))
.await
.unwrap();
let before = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert!(before.constraints.is_empty());
repository
.review(
"term-sensei",
GlossaryReviewStatus::Approved,
"reviewer",
Some("ok".to_string()),
)
.await
.unwrap();
let after = repository
.diagnose("Sensei", &BTreeMap::new())
.await
.unwrap();
assert_eq!(after.constraints.len(), 1);
let term = repository.find("term-sensei").await.unwrap();
assert_eq!(term.history.len(), 2);
assert_eq!(term.history[1].action, "approved");
let summary = repository.summary().await.unwrap();
assert_eq!(summary.approved_count, 1);
}
}