use std::{
collections::{BTreeMap, BTreeSet},
fs,
path::PathBuf,
};
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{
config::Config,
local::{Dictionary, flatten, set_dotted, sort_object},
};
const STATE_FILE: &str = ".lingva/remote-state.json";
const STATE_SCHEMA_VERSION: u8 = 1;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct LocaleState {
pub revision: String,
pub base: Dictionary,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RemoteState {
pub schema_version: u8,
pub project_id: String,
pub environment: String,
pub locales: BTreeMap<String, LocaleState>,
}
impl RemoteState {
pub fn empty(config: &Config) -> Self {
Self {
schema_version: STATE_SCHEMA_VERSION,
project_id: config.project_id.clone(),
environment: config.environment.clone(),
locales: BTreeMap::new(),
}
}
pub fn load(config: &Config) -> Self {
let parsed = fs::read_to_string(path(config))
.ok()
.and_then(|source| serde_json::from_str::<Self>(&source).ok());
parsed
.filter(|state| {
state.schema_version == STATE_SCHEMA_VERSION
&& state.project_id == config.project_id
&& state.environment == config.environment
})
.unwrap_or_else(|| Self::empty(config))
}
pub fn save(&self, config: &Config) -> Result<()> {
let path = path(config);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(self)?))?;
Ok(())
}
pub fn set_locale(&mut self, locale: &str, revision: &str, base: &Dictionary) {
self.locales.insert(
locale.to_owned(),
LocaleState {
revision: revision.to_owned(),
base: sort_object(base),
},
);
}
pub fn base_revision(&self, locales: &[String], force: bool) -> Result<Option<String>> {
if force {
return Ok(None);
}
let revisions = locales
.iter()
.filter_map(|locale| self.locales.get(locale).map(|state| state.revision.clone()))
.collect::<BTreeSet<_>>();
if revisions.len() > 1 {
bail!(
"Lingva push found locales from different remote revisions. Pull them together before pushing."
);
}
Ok(revisions.into_iter().next())
}
}
fn path(config: &Config) -> PathBuf {
config
.path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(STATE_FILE)
}
pub fn merge_remote(
locale: &str,
base: &Dictionary,
local: &Dictionary,
remote: &Dictionary,
) -> Result<Dictionary> {
let base = flatten(base)?;
let local = flatten(local)?;
let remote = flatten(remote)?;
let keys = base
.keys()
.chain(local.keys())
.chain(remote.keys())
.cloned()
.collect::<BTreeSet<_>>();
let mut merged = Dictionary::new();
let mut conflicts = Vec::new();
for key in keys {
let base_value = base.get(&key);
let local_value = local.get(&key);
let remote_value = remote.get(&key);
let selected: Option<&Value> = if local_value == remote_value {
local_value
} else if local_value == base_value {
remote_value
} else if remote_value == base_value {
local_value
} else {
conflicts.push(key.clone());
None
};
if let Some(value) = selected {
set_dotted(&mut merged, &key, value.clone())?;
}
}
if !conflicts.is_empty() {
bail!(
"Lingva pull found conflicts in locale \"{locale}\": {}. Resolve local or hosted values, then pull again.",
conflicts.join(", ")
);
}
Ok(sort_object(&merged))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn dictionary(value: Value) -> Dictionary {
serde_json::from_value(value).unwrap()
}
#[test]
fn three_way_merge_keeps_independent_changes() {
let merged = merge_remote(
"ru",
&dictionary(json!({"a":"1","b":"1"})),
&dictionary(json!({"a":"2","b":"1"})),
&dictionary(json!({"a":"1","b":"2"})),
)
.unwrap();
assert_eq!(merged, dictionary(json!({"a":"2","b":"2"})));
}
#[test]
fn three_way_merge_rejects_same_key_conflict() {
let error = merge_remote(
"ru",
&dictionary(json!({"a":"1"})),
&dictionary(json!({"a":"2"})),
&dictionary(json!({"a":"3"})),
)
.unwrap_err();
assert!(error.to_string().contains("conflicts"));
}
}