Files
fluxer/tools/i18n_auto/src/reviewed_unchanged.rs
T

262 lines
8.0 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::{BTreeMap, BTreeSet};
use std::fs::{self, OpenOptions};
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::thread;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
const REVIEWED_UNCHANGED_VERSION: u32 = 1;
const LOCK_TIMEOUT: Duration = Duration::from_secs(30);
const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(50);
#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
pub struct ReviewedUnchangedEntry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub msgctxt: Option<String>,
pub msgid: String,
}
impl ReviewedUnchangedEntry {
pub fn new(msgctxt: Option<&str>, msgid: &str) -> Self {
Self {
msgctxt: msgctxt.map(str::to_string),
msgid: msgid.to_string(),
}
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
struct ReviewedUnchangedFile {
version: u32,
#[serde(default)]
locales: BTreeMap<String, Vec<ReviewedUnchangedEntry>>,
}
impl Default for ReviewedUnchangedFile {
fn default() -> Self {
Self {
version: REVIEWED_UNCHANGED_VERSION,
locales: BTreeMap::new(),
}
}
}
pub struct ReviewedUnchangedStore {
path: PathBuf,
data: ReviewedUnchangedFile,
dirty_locales: BTreeSet<String>,
}
impl ReviewedUnchangedStore {
pub fn load(path: impl Into<PathBuf>) -> Result<Self> {
let path = path.into();
let data = read_store_file(&path)?;
Ok(Self {
path,
data,
dirty_locales: BTreeSet::new(),
})
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn contains(&self, locale: &str, msgctxt: Option<&str>, msgid: &str) -> bool {
let needle = ReviewedUnchangedEntry::new(msgctxt, msgid);
self.data
.locales
.get(locale)
.is_some_and(|entries| entries.binary_search(&needle).is_ok())
}
pub fn mark(&mut self, locale: &str, msgctxt: Option<&str>, msgid: &str) {
let entry = ReviewedUnchangedEntry::new(msgctxt, msgid);
let entries = self.data.locales.entry(locale.to_string()).or_default();
match entries.binary_search(&entry) {
Ok(_) => {}
Err(index) => {
entries.insert(index, entry);
self.dirty_locales.insert(locale.to_string());
}
}
}
pub fn unmark(&mut self, locale: &str, msgctxt: Option<&str>, msgid: &str) {
let entry = ReviewedUnchangedEntry::new(msgctxt, msgid);
let Some(entries) = self.data.locales.get_mut(locale) else {
return;
};
let Ok(index) = entries.binary_search(&entry) else {
return;
};
entries.remove(index);
if entries.is_empty() {
self.data.locales.remove(locale);
}
self.dirty_locales.insert(locale.to_string());
}
pub fn locales(&self) -> impl Iterator<Item = (&str, &[ReviewedUnchangedEntry])> {
self.data
.locales
.iter()
.map(|(locale, entries)| (locale.as_str(), entries.as_slice()))
}
pub fn retain_locale(
&mut self,
locale: &str,
mut keep: impl FnMut(&ReviewedUnchangedEntry) -> bool,
) -> usize {
let Some(entries) = self.data.locales.get_mut(locale) else {
return 0;
};
let before = entries.len();
entries.retain(|entry| keep(entry));
let removed = before - entries.len();
if removed == 0 {
return 0;
}
if entries.is_empty() {
self.data.locales.remove(locale);
}
self.dirty_locales.insert(locale.to_string());
removed
}
pub fn clear_locale(&mut self, locale: &str) {
if self.data.locales.remove(locale).is_some() {
self.dirty_locales.insert(locale.to_string());
}
}
pub fn save_if_dirty(&mut self) -> Result<()> {
if self.dirty_locales.is_empty() {
return Ok(());
}
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let _lock = SidecarLock::acquire(&self.path)?;
let mut merged = read_store_file(&self.path)?;
for locale in &self.dirty_locales {
match self.data.locales.get(locale) {
Some(entries) if !entries.is_empty() => {
let mut entries = entries.clone();
normalize_entries(&mut entries);
merged.locales.insert(locale.clone(), entries);
}
_ => {
merged.locales.remove(locale);
}
}
}
write_store_file(&self.path, &merged)?;
self.data = merged;
self.dirty_locales.clear();
Ok(())
}
}
fn read_store_file(path: &Path) -> Result<ReviewedUnchangedFile> {
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) if error.kind() == ErrorKind::NotFound => {
return Ok(ReviewedUnchangedFile::default());
}
Err(error) => {
return Err(error).with_context(|| format!("failed to read {}", path.display()));
}
};
let mut data = serde_json::from_str::<ReviewedUnchangedFile>(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
if data.version != REVIEWED_UNCHANGED_VERSION {
bail!(
"unsupported reviewed-unchanged sidecar version {} in {}",
data.version,
path.display()
);
}
for entries in data.locales.values_mut() {
normalize_entries(entries);
}
data.locales.retain(|_, entries| !entries.is_empty());
Ok(data)
}
fn normalize_entries(entries: &mut Vec<ReviewedUnchangedEntry>) {
entries.sort();
entries.dedup();
}
fn write_store_file(path: &Path, data: &ReviewedUnchangedFile) -> Result<()> {
let mut content = serde_json::to_string_pretty(data)
.with_context(|| format!("failed to serialize {}", path.display()))?;
content.push('\n');
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_path = path.with_file_name(format!(
".{}.{}.{}.tmp",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("auto-i18n-reviewed-unchanged.json"),
std::process::id(),
timestamp
));
fs::write(&temp_path, content)
.with_context(|| format!("failed to write {}", temp_path.display()))?;
fs::rename(&temp_path, path)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}
struct SidecarLock {
path: PathBuf,
}
impl SidecarLock {
fn acquire(sidecar_path: &Path) -> Result<Self> {
let lock_path = sidecar_path.with_extension("json.lock");
let started = Instant::now();
loop {
match OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => {
return Ok(Self { path: lock_path });
}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
if started.elapsed() >= LOCK_TIMEOUT {
bail!(
"timed out waiting for reviewed-unchanged sidecar lock {}",
lock_path.display()
);
}
thread::sleep(LOCK_POLL_INTERVAL);
}
Err(error) => {
return Err(error)
.with_context(|| format!("failed to create {}", lock_path.display()));
}
}
}
}
}
impl Drop for SidecarLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}