lingva 0.1.0

Standalone Lingva localization CLI
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"));
    }
}