use std::collections::HashMap;
use std::path::Path;
use rusqlite::params;
use super::journal::{open_journal, UNSTORABLE_ENTRY};
use crate::error::{EngineError, Result};
use crate::types::{CandidateSource, WordItem};
pub type FindCandidate<'a> = dyn FnMut(&str, &str) -> Option<WordItem> + 'a;
const FIXED_SLOT_COUNT: usize = 5;
pub fn set_fixed_position(
user_db: &Path,
context: &str,
entry_key: &str,
value: &str,
position: i32,
) -> Result<()> {
if !(1..=FIXED_SLOT_COUNT as i32).contains(&position)
|| context.is_empty()
|| entry_key.is_empty()
|| value.is_empty()
{
return Err(EngineError::invalid(UNSTORABLE_ENTRY));
}
let journal = open_journal(user_db)?;
journal.execute_batch("BEGIN IMMEDIATE")?;
journal
.prepare_cached(
"DELETE FROM fixed_candidate_positions WHERE context_key=?1 AND position=?2",
)?
.execute(params![context, position])?;
journal
.prepare_cached(
"INSERT INTO fixed_candidate_positions(context_key,entry_key,value,position) VALUES(?1,?2,?3,?4) ON CONFLICT(context_key,entry_key,value) DO UPDATE SET position=excluded.position",
)?
.execute(params![context, entry_key, value, position])?;
journal.execute_batch("COMMIT")?;
Ok(())
}
pub fn clear_fixed_position(
user_db: &Path,
context: &str,
entry_key: &str,
value: &str,
) -> Result<()> {
let journal = open_journal(user_db)?;
journal
.prepare_cached(
"DELETE FROM fixed_candidate_positions WHERE context_key=?1 AND entry_key=?2 AND value=?3",
)?
.execute(params![context, entry_key, value])?;
Ok(())
}
pub fn is_fixed(user_db: &Path, context: &str, entry_key: &str, value: &str) -> bool {
let Ok(journal) = open_journal(user_db) else {
return false;
};
journal
.prepare_cached(
"SELECT 1 FROM fixed_candidate_positions WHERE context_key=?1 AND entry_key=?2 AND value=?3",
)
.and_then(|mut statement| statement.exists(params![context, entry_key, value]))
.unwrap_or(false)
}
fn fixed_rows(user_db: &Path, context: &str) -> Result<Vec<(String, String, i32)>> {
let journal = open_journal(user_db)?;
let mut statement = journal.prepare_cached(
"SELECT entry_key,value,position FROM fixed_candidate_positions WHERE context_key=?1 ORDER BY position",
)?;
let rows = statement.query_map(params![context], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
})?;
let mut result = Vec::new();
for row in rows {
let row = row?;
if result.is_empty() {
result.reserve_exact(FIXED_SLOT_COUNT);
}
result.push(row);
}
Ok(result)
}
fn fixed_row_contains(rows: &[(WordItem, i32)], word: &str) -> bool {
rows.iter().any(|(item, _)| item.word == word)
}
pub fn apply_fixed_positions(
user_db: &Path,
context: &str,
candidates: &mut Vec<WordItem>,
include_missing: bool,
find_candidate: Option<&mut FindCandidate<'_>>,
keep_dynamic_candidate_positions: bool,
) {
if context.is_empty() || candidates.is_empty() {
return;
}
let journal_exists = user_db.try_exists().unwrap_or(false);
apply_fixed_positions_with_state(
user_db,
context,
candidates,
include_missing,
find_candidate,
keep_dynamic_candidate_positions,
journal_exists,
);
}
pub(crate) fn apply_fixed_positions_with_state(
user_db: &Path,
context: &str,
candidates: &mut Vec<WordItem>,
include_missing: bool,
mut find_candidate: Option<&mut FindCandidate<'_>>,
keep_dynamic_candidate_positions: bool,
journal_exists: bool,
) {
if context.is_empty() || candidates.is_empty() {
return;
}
let fixed = if journal_exists {
let Ok(fixed) = fixed_rows(user_db, context) else {
return;
};
fixed
} else {
Vec::new()
};
let mut dynamic_candidates = Vec::new();
if !keep_dynamic_candidate_positions
&& candidates
.iter()
.any(|item| item.source.takes_online_seat())
{
let original = std::mem::take(candidates);
let (retained, dynamic): (Vec<_>, Vec<_>) = original
.into_iter()
.partition(|item| !item.source.takes_online_seat());
*candidates = retained;
dynamic_candidates = dynamic;
}
if fixed.is_empty() && dynamic_candidates.is_empty() {
return;
}
let candidate_by_word: HashMap<&str, &WordItem> = candidates
.iter()
.map(|item| (item.word.as_str(), item))
.collect();
let mut rows: Vec<(WordItem, i32)> = Vec::with_capacity(fixed.len());
for (entry_key, value, position) in fixed {
let found = match candidate_by_word.get(value.as_str()) {
Some(existing) => Some((*existing).clone()),
None if include_missing => find_candidate
.as_deref_mut()
.and_then(|find| find(&entry_key, &value)),
None => None,
};
if let Some(mut item) = found {
item.fixed_position = position;
rows.push((item, position));
}
}
candidates.retain(|item| !fixed_row_contains(&rows, &item.word));
for (item, position) in rows {
let index = usize::try_from(position - 1)
.unwrap_or(0)
.min(candidates.len());
candidates.insert(index, item);
}
let mut ai = Vec::new();
for item in dynamic_candidates {
if item.source == CandidateSource::CloudSuggestion {
let index = 1.min(candidates.len());
candidates.insert(index, item);
} else {
ai.push(item);
}
}
candidates.append(&mut ai);
}
pub fn record_pinned_candidate(user_db: &Path, context: &str, value: &str) -> Result<()> {
let journal = open_journal(user_db)?;
journal
.prepare_cached(
"INSERT INTO pinned_candidates(context_key,value) VALUES(?1,?2) ON CONFLICT(context_key) DO UPDATE SET value=excluded.value,updated_at=unixepoch()",
)?
.execute(params![context, value])?;
Ok(())
}
pub fn is_pinned_candidate(user_db: &Path, context: &str, value: &str) -> bool {
if !user_db.try_exists().unwrap_or(false) {
return false;
}
let Ok(journal) = open_journal(user_db) else {
return false;
};
journal
.prepare_cached("SELECT 1 FROM pinned_candidates WHERE context_key=?1 AND value=?2")
.and_then(|mut statement| statement.exists(params![context, value]))
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::super::journal::test_support::{item, Dir};
use super::*;
fn words(items: &[WordItem]) -> Vec<&str> {
items.iter().map(|item| item.word.as_str()).collect()
}
fn online(word: &str, source: CandidateSource) -> WordItem {
WordItem::new("", word, 1, source, "")
}
#[test]
fn fixed_word_scan_avoids_temporary_hash_allocation() {
let rows = vec![(item("ni", "甲", 1), 1), (item("ni", "乙", 1), 2)];
let (found, allocations) =
crate::ime::personal_rerank::allocations::count(|| fixed_row_contains(&rows, "乙"));
assert!(found);
assert_eq!(allocations, 0);
}
#[test]
fn ordinary_rows_skip_dynamic_partition_when_no_online_candidates_exist() {
let dir = Dir::new();
let journal = dir.journal();
let mut list = vec![item("ni", "甲", 3), item("ni", "乙", 2)];
let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
apply_fixed_positions(&journal, "ni", &mut list, false, None, false);
});
assert_eq!(words(&list), ["甲", "乙"]);
assert_eq!(
allocations, 0,
"ordinary rows allocated {allocations} temporary buffers"
);
}
#[test]
fn slots_are_validated_unique_and_cleared() {
let dir = Dir::new();
let journal = dir.journal();
for position in [0, 6] {
assert!(set_fixed_position(&journal, "ni'hao", "ni'hao", "拟好", position).is_err());
}
assert!(set_fixed_position(&journal, "", "ni'hao", "拟好", 1).is_err());
set_fixed_position(&journal, "ni'hao", "ni'hao", "拟好", 1).unwrap();
assert!(is_fixed(&journal, "ni'hao", "ni'hao", "拟好"));
set_fixed_position(&journal, "ni'hao", "ni'hao", "你好", 1).unwrap();
assert!(!is_fixed(&journal, "ni'hao", "ni'hao", "拟好"));
set_fixed_position(&journal, "ni'hao", "ni'hao", "你好", 3).unwrap();
let mut list = vec![
item("ni'hao", "甲", 3),
item("ni'hao", "乙", 2),
item("ni'hao", "你好", 1),
];
apply_fixed_positions(&journal, "ni'hao", &mut list, false, None, false);
assert_eq!(words(&list), ["甲", "乙", "你好"]);
assert_eq!(list[2].fixed_position, 3);
clear_fixed_position(&journal, "ni'hao", "ni'hao", "你好").unwrap();
assert!(!is_fixed(&journal, "ni'hao", "ni'hao", "你好"));
}
#[test]
fn fixed_rows_move_by_word_and_online_rows_return_to_their_slots() {
let dir = Dir::new();
let journal = dir.journal();
set_fixed_position(&journal, "ni", "ni", "丙", 1).unwrap();
set_fixed_position(&journal, "ni", "ni", "戊", 2).unwrap();
set_fixed_position(&journal, "ni", "ni", "庚", 5).unwrap();
let mut list = vec![
item("ni", "甲", 6),
online("云", CandidateSource::CloudSuggestion),
online("智", CandidateSource::AiSuggestion),
item("ni", "乙", 5),
item("ni", "丙", 4),
];
let mut found =
|key: &str, word: &str| (key == "ni" && word == "戊").then(|| item("ni", "戊", 1));
apply_fixed_positions(&journal, "ni", &mut list, true, Some(&mut found), false);
assert_eq!(words(&list), ["丙", "云", "戊", "甲", "乙", "智"]);
assert_eq!(list[0].fixed_position, 1);
assert_eq!(list[2].fixed_position, 2);
let mut list = vec![
item("ni", "甲", 6),
online("云", CandidateSource::CloudSuggestion),
item("ni", "丙", 4),
];
let mut never = |_: &str, _: &str| -> Option<WordItem> { panic!("looked up") };
apply_fixed_positions(&journal, "ni", &mut list, false, Some(&mut never), true);
assert_eq!(words(&list), ["丙", "甲", "云"]);
}
#[test]
fn fixed_rows_reserve_the_five_available_slots() {
let dir = Dir::new();
let journal = dir.journal();
for position in 1..=5 {
set_fixed_position(&journal, "ni", "ni", &format!("字{position}"), position).unwrap();
}
let rows = fixed_rows(&journal, "ni").unwrap();
assert_eq!(rows.len(), 5);
assert_eq!(rows.capacity(), 5);
}
#[test]
fn empty_fixed_rows_do_not_reserve_unused_slots() {
let dir = Dir::new();
let journal = dir.journal();
let rows = fixed_rows(&journal, "ni").unwrap();
assert!(rows.is_empty());
assert_eq!(rows.capacity(), 0);
}
#[test]
fn a_missing_journal_fixes_nothing_and_is_not_created() {
let dir = Dir::new();
let journal = dir.journal();
let mut list = vec![
item("ni", "甲", 3),
item("ni", "乙", 2),
online("智", CandidateSource::AiSuggestion),
item("ni", "丙", 1),
online("云", CandidateSource::CloudSuggestion),
];
apply_fixed_positions(&journal, "ni", &mut list, true, None, false);
assert_eq!(words(&list), ["甲", "云", "乙", "丙", "智"]);
assert!(!is_pinned_candidate(&journal, "ni", "甲"));
assert!(!journal.exists());
}
#[test]
fn ai_rows_go_last_and_leave_the_other_rows_in_place() {
let dir = Dir::new();
let journal = dir.journal();
set_fixed_position(&journal, "ni", "ni", "丁", 2).unwrap();
let apply = |list: &mut Vec<WordItem>| {
apply_fixed_positions(&journal, "ni", list, true, None, false);
};
let mut local = vec![
item("ni", "甲", 3),
item("ni", "乙", 2),
item("ni", "丙", 1),
item("ni", "丁", 1),
];
apply(&mut local);
assert_eq!(words(&local), ["甲", "丁", "乙", "丙"]);
let mut answered = vec![
item("ni", "甲", 3),
item("ni", "乙", 2),
online("智一", CandidateSource::AiSuggestion),
item("ni", "丙", 1),
item("ni", "丁", 1),
online("智二", CandidateSource::AiSuggestion),
];
apply(&mut answered);
assert_eq!(words(&answered), ["甲", "丁", "乙", "丙", "智一", "智二"]);
}
#[test]
fn a_later_pin_replaces_the_earlier_one() {
let dir = Dir::new();
let journal = dir.journal();
record_pinned_candidate(&journal, "ni'hao", "拟好").unwrap();
assert!(is_pinned_candidate(&journal, "ni'hao", "拟好"));
record_pinned_candidate(&journal, "ni'hao", "你好").unwrap();
assert!(!is_pinned_candidate(&journal, "ni'hao", "拟好"));
assert!(is_pinned_candidate(&journal, "ni'hao", "你好"));
assert!(!is_pinned_candidate(&journal, "ni", "你好"));
}
}