use std::path::PathBuf;
use indexmap::IndexMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::GettextError;
use crate::model::GettextFile;
use crate::service::merger::{self, MergeOptions};
use crate::service::parser;
use crate::service::GettextStoreManager;
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct SyncWithPotParams {
pub path: Option<String>,
pub pot_path: String,
pub dry_run: Option<bool>,
pub mark_changed_as_fuzzy: Option<bool>,
}
pub(crate) async fn handle_sync_with_pot(
manager: &GettextStoreManager,
params: SyncWithPotParams,
) -> Result<Value, GettextError> {
if params.pot_path.trim().is_empty() {
return Err(GettextError::InvalidInput(
"pot_path must not be empty".into(),
));
}
let dry_run = params.dry_run.unwrap_or(false);
let opts = MergeOptions {
mark_changed_as_fuzzy: params.mark_changed_as_fuzzy.unwrap_or(true),
};
let pot_path = PathBuf::from(¶ms.pot_path);
manager.validate_path(&pot_path)?;
let file_store = manager.file_store().clone();
let pot_path_clone = pot_path.clone();
let pot_content = tokio::task::spawn_blocking(move || file_store.read(&pot_path_clone))
.await
.map_err(|e| GettextError::Io(std::io::Error::other(e)))??;
let pot_file = parser::parse_po(&pot_content)?;
let store = manager.store_for(params.path.as_deref()).await?;
let target_snapshot = build_file_snapshot(&store).await?;
let (merged, report) = merger::merge(&target_snapshot, &pot_file, opts);
if !dry_run {
persist_merge(&store, &target_snapshot, &merged).await?;
}
Ok(merger::report_to_json(&report, dry_run))
}
async fn build_file_snapshot(
store: &crate::service::GettextStore,
) -> Result<GettextFile, GettextError> {
let mut file = GettextFile::new();
file.metadata = store.metadata().await?;
file.rebuild_header_entry();
for (msgid, msgctxt, entry) in store.list_all().await? {
file.entries.insert((msgid, msgctxt), entry);
}
Ok(file)
}
async fn persist_merge(
store: &crate::service::GettextStore,
before: &GettextFile,
after: &GettextFile,
) -> Result<(), GettextError> {
sync_headers(store, &before.metadata, &after.metadata).await?;
for ((msgid, msgctxt), _) in &before.entries {
if msgid.is_empty() && msgctxt.is_none() {
continue;
}
let key = (msgid.clone(), msgctxt.clone());
if !after.entries.contains_key(&key) {
store.delete(msgid, msgctxt.as_deref()).await?;
}
}
for ((msgid, msgctxt), new_entry) in &after.entries {
if msgid.is_empty() && msgctxt.is_none() {
continue;
}
store
.update_entry(msgid, msgctxt.as_deref(), new_entry.clone())
.await?;
}
write_obsolete_lines(store, &after.obsolete_lines).await?;
Ok(())
}
async fn sync_headers(
store: &crate::service::GettextStore,
before: &IndexMap<String, String>,
after: &IndexMap<String, String>,
) -> Result<(), GettextError> {
for (k, v) in after {
if before.get(k) != Some(v) {
store.set_header(k, v).await?;
}
}
for k in before.keys() {
if !after.contains_key(k) {
store.remove_header(k).await?;
}
}
Ok(())
}
async fn write_obsolete_lines(
store: &crate::service::GettextStore,
obsolete_lines: &[String],
) -> Result<(), GettextError> {
use crate::service::serializer::serialize_po;
let mut snapshot = GettextFile::new();
snapshot.metadata = store.metadata().await?;
snapshot.rebuild_header_entry();
for (msgid, msgctxt, entry) in store.list_all().await? {
snapshot.entries.insert((msgid, msgctxt), entry);
}
snapshot.obsolete_lines = obsolete_lines.to_vec();
let content = serialize_po(&snapshot);
let path = store.path().to_path_buf();
let fs_clone = crate::io::FsFileStore::new();
use crate::io::FileStore as _;
let path_clone = path.clone();
tokio::task::spawn_blocking(move || fs_clone.write(&path_clone, &content))
.await
.map_err(|e| GettextError::Io(std::io::Error::other(e)))??;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn write_pot(path: &std::path::Path, body: &str) {
std::fs::write(path, body).unwrap();
}
#[tokio::test]
async fn sync_adds_obsoletes_and_unchanged_in_one_pass() {
let dir = tempfile::TempDir::new().unwrap();
let po = dir.path().join("messages.po");
let pot = dir.path().join("messages.pot");
let manager = Arc::new(GettextStoreManager::new(Some(po.clone())));
let store = manager.store_for(None).await.unwrap();
store.upsert("Hello", None, "Bonjour", None).await.unwrap();
store.upsert("Old", None, "Vieux", None).await.unwrap();
store.set_header("Language", "fr").await.unwrap();
write_pot(
&pot,
r#"
msgid ""
msgstr ""
"POT-Creation-Date: 2026-05-27 12:00+0000\n"
msgid "Hello"
msgstr ""
msgid "NewItem"
msgstr ""
"#,
);
let result = handle_sync_with_pot(
&manager,
SyncWithPotParams {
path: Some(po.to_str().unwrap().into()),
pot_path: pot.to_str().unwrap().into(),
dry_run: Some(false),
mark_changed_as_fuzzy: None,
},
)
.await
.unwrap();
assert_eq!(result["added"], serde_json::json!(["NewItem"]));
assert_eq!(result["obsoleted"], serde_json::json!(["Old"]));
assert_eq!(result["unchanged"], 1);
assert_eq!(result["dry_run"], false);
let store = manager.store_for(None).await.unwrap();
let hello = store.get("Hello", None).await.unwrap();
assert_eq!(hello.msgstr, "Bonjour");
assert!(store.get("Old", None).await.is_err());
let new_item = store.get("NewItem", None).await.unwrap();
assert_eq!(new_item.msgstr, "");
let meta = store.metadata().await.unwrap();
assert_eq!(
meta.get("POT-Creation-Date").map(String::as_str),
Some("2026-05-27 12:00+0000")
);
}
#[tokio::test]
async fn sync_dry_run_does_not_mutate() {
let dir = tempfile::TempDir::new().unwrap();
let po = dir.path().join("messages.po");
let pot = dir.path().join("messages.pot");
let manager = Arc::new(GettextStoreManager::new(Some(po.clone())));
let store = manager.store_for(None).await.unwrap();
store.upsert("Hello", None, "Bonjour", None).await.unwrap();
store.upsert("Old", None, "Vieux", None).await.unwrap();
write_pot(
&pot,
r#"
msgid ""
msgstr ""
msgid "Hello"
msgstr ""
msgid "NewItem"
msgstr ""
"#,
);
let result = handle_sync_with_pot(
&manager,
SyncWithPotParams {
path: Some(po.to_str().unwrap().into()),
pot_path: pot.to_str().unwrap().into(),
dry_run: Some(true),
mark_changed_as_fuzzy: None,
},
)
.await
.unwrap();
assert_eq!(result["dry_run"], true);
assert_eq!(result["added"], serde_json::json!(["NewItem"]));
let old = store.get("Old", None).await.unwrap();
assert_eq!(old.msgstr, "Vieux");
assert!(store.get("NewItem", None).await.is_err());
}
#[tokio::test]
async fn sync_real_sample_fr_po_against_synthetic_pot() {
let dir = tempfile::TempDir::new().unwrap();
let po = dir.path().join("sample_fr.po");
let pot = dir.path().join("messages.pot");
let src = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/sample_fr.po"),
)
.unwrap();
std::fs::write(&po, &src).unwrap();
write_pot(
&pot,
r#"
msgid ""
msgstr ""
"POT-Creation-Date: 2026-05-27 12:00+0000\n"
msgid "Hello"
msgstr ""
msgctxt "greeting"
msgid "Hello, World!"
msgstr ""
msgid "NewString"
msgstr ""
"#,
);
let manager = Arc::new(GettextStoreManager::new(Some(po.clone())));
let result = handle_sync_with_pot(
&manager,
SyncWithPotParams {
path: Some(po.to_str().unwrap().into()),
pot_path: pot.to_str().unwrap().into(),
dry_run: Some(false),
mark_changed_as_fuzzy: None,
},
)
.await
.unwrap();
assert!(result["added"]
.as_array()
.unwrap()
.iter()
.any(|v| v == "NewString"));
let obsoleted = result["obsoleted"].as_array().unwrap();
assert!(!obsoleted.is_empty());
let store = manager.store_for(None).await.unwrap();
assert_eq!(store.get("Hello", None).await.unwrap().msgstr, "Bonjour");
}
#[tokio::test]
async fn sync_marks_fuzzy_on_plural_drift() {
let dir = tempfile::TempDir::new().unwrap();
let po = dir.path().join("messages.po");
let pot = dir.path().join("messages.pot");
let manager = Arc::new(GettextStoreManager::new(Some(po.clone())));
let store = manager.store_for(None).await.unwrap();
store
.upsert_full(
"%d cat",
None,
"",
Some("%d cats"),
Some(vec!["%d chat".into(), "%d chats".into()]),
None,
)
.await
.unwrap();
write_pot(
&pot,
"msgid \"\"\nmsgstr \"\"\n\nmsgid \"%d cat\"\nmsgid_plural \"%d cats (updated)\"\nmsgstr[0] \"\"\nmsgstr[1] \"\"\n",
);
let result = handle_sync_with_pot(
&manager,
SyncWithPotParams {
path: Some(po.to_str().unwrap().into()),
pot_path: pot.to_str().unwrap().into(),
dry_run: Some(false),
mark_changed_as_fuzzy: Some(true),
},
)
.await
.unwrap();
assert_eq!(result["updated"], serde_json::json!(["%d cat"]));
let store = manager.store_for(None).await.unwrap();
let entry = store.get("%d cat", None).await.unwrap();
assert!(entry.is_fuzzy(), "merge should mark drifted plural fuzzy");
assert_eq!(entry.msgid_plural.as_deref(), Some("%d cats (updated)"));
assert_eq!(entry.msgstr_plural, vec!["%d chat", "%d chats"]);
}
#[tokio::test]
async fn sync_rejects_empty_pot_path() {
let dir = tempfile::TempDir::new().unwrap();
let po = dir.path().join("messages.po");
let manager = Arc::new(GettextStoreManager::new(Some(po.clone())));
let _ = manager.store_for(None).await.unwrap();
let result = handle_sync_with_pot(
&manager,
SyncWithPotParams {
path: Some(po.to_str().unwrap().into()),
pot_path: "".into(),
dry_run: None,
mark_changed_as_fuzzy: None,
},
)
.await;
assert!(result.is_err());
}
}