use std::{
collections::BTreeMap,
fs,
process::Command,
sync::mpsc::{self, Receiver},
thread,
};
use serde_json::Value;
use tiny_http::{Header, Response, Server, StatusCode};
fn lingva(root: &std::path::Path) -> Command {
let mut command = Command::new(env!("CARGO_BIN_EXE_lingva"));
command.current_dir(root);
command
}
#[derive(Debug)]
struct CapturedRequest {
method: String,
url: String,
headers: BTreeMap<String, String>,
body: String,
}
type MockResponse = (u16, Vec<(&'static str, &'static str)>, &'static str);
fn serve_once(
status: u16,
headers: &[(&str, &str)],
body: &str,
) -> (String, Receiver<CapturedRequest>) {
let server = Server::http("127.0.0.1:0").unwrap();
let address = format!("http://{}", server.server_addr());
let response_headers = headers
.iter()
.map(|(name, value)| Header::from_bytes(*name, *value).unwrap())
.collect::<Vec<_>>();
let body = body.to_owned();
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
let mut request = server.recv().unwrap();
let mut request_body = String::new();
request
.as_reader()
.read_to_string(&mut request_body)
.unwrap();
let _ = sender.send(CapturedRequest {
method: request.method().to_string(),
url: request.url().to_owned(),
headers: request
.headers()
.iter()
.map(|header| {
(
header.field.as_str().to_ascii_lowercase().to_string(),
header.value.as_str().to_owned(),
)
})
.collect(),
body: request_body,
});
let mut response = Response::from_string(body).with_status_code(StatusCode(status));
for header in response_headers {
response = response.with_header(header);
}
request.respond(response).unwrap();
});
(address, receiver)
}
fn serve_sequence(responses: Vec<MockResponse>) -> (String, Receiver<CapturedRequest>) {
let server = Server::http("127.0.0.1:0").unwrap();
let address = format!("http://{}", server.server_addr());
let (sender, receiver) = mpsc::channel();
thread::spawn(move || {
for (status, headers, body) in responses {
let mut request = server.recv().unwrap();
let mut request_body = String::new();
request
.as_reader()
.read_to_string(&mut request_body)
.unwrap();
let _ = sender.send(CapturedRequest {
method: request.method().to_string(),
url: request.url().to_owned(),
headers: request
.headers()
.iter()
.map(|header| {
(
header.field.as_str().to_ascii_lowercase().to_string(),
header.value.as_str().to_owned(),
)
})
.collect(),
body: request_body,
});
let mut response = Response::from_string(body).with_status_code(StatusCode(status));
for (name, value) in headers {
response = response.with_header(Header::from_bytes(name, value).unwrap());
}
request.respond(response).unwrap();
}
});
(address, receiver)
}
fn write_remote_config(root: &std::path::Path, api_url: &str, locales: &str) {
fs::write(
root.join("lingva.config.yaml"),
format!(
"projectId: demo\nenvironment: stage\nlocales: [{locales}]\ndefaultLocale: en\nfallbackLocale: en\ntranslationFiles: .lingva/translations/{{locale}}.json\napiUrl: {api_url}\n"
),
)
.unwrap();
}
#[test]
fn help_exposes_the_portable_customer_surface() {
let root = tempfile::tempdir().unwrap();
let output = lingva(root.path()).arg("--help").output().unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
assert!(output.status.success());
for command in ["auth", "init", "update", "sync", "check", "pull", "push"] {
assert!(stdout.contains(command), "missing {command} from help");
}
}
#[test]
fn version_and_invalid_command_follow_cli_exit_contract() {
let root = tempfile::tempdir().unwrap();
let version = lingva(root.path()).arg("--version").output().unwrap();
assert!(version.status.success());
assert_eq!(
String::from_utf8(version.stdout).unwrap().trim(),
concat!("lingva ", env!("CARGO_PKG_VERSION"))
);
let invalid = lingva(root.path()).arg("unknown").output().unwrap();
assert!(!invalid.status.success());
assert!(String::from_utf8_lossy(&invalid.stderr).contains("unrecognized subcommand"));
}
#[test]
fn init_requires_explicit_account_free_mode() {
let root = tempfile::tempdir().unwrap();
let no_auth_remote = lingva(root.path())
.args(["init", "--no-auth"])
.output()
.unwrap();
assert!(!no_auth_remote.status.success());
assert!(
String::from_utf8_lossy(&no_auth_remote.stderr).contains("only valid with --mode local")
);
let unauthenticated = lingva(root.path())
.args(["init", "--mode", "local"])
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(!unauthenticated.status.success());
assert!(String::from_utf8_lossy(&unauthenticated.stderr).contains("auth login"));
}
#[test]
fn init_dry_run_reports_files_without_writing_and_force_controls_overwrite() {
let root = tempfile::tempdir().unwrap();
let dry_run = lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--sdk",
"react",
"--project-id",
"demo",
"--dry-run",
"--format",
"json",
])
.output()
.unwrap();
assert!(dry_run.status.success());
let payload: Value = serde_json::from_slice(&dry_run.stdout).unwrap();
assert_eq!(payload["written"], false);
assert!(!root.path().join("lingva.config.yaml").exists());
assert!(!root.path().join("lingva.config.ts").exists());
assert!(
lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--project-id",
"one"
])
.status()
.unwrap()
.success()
);
let refused = lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--project-id",
"two",
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(String::from_utf8_lossy(&refused.stderr).contains("--force"));
assert!(
lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--project-id",
"two",
"--force",
])
.status()
.unwrap()
.success()
);
assert!(
fs::read_to_string(root.path().join("lingva.config.yaml"))
.unwrap()
.contains("projectId: two")
);
}
#[test]
fn local_init_can_generate_runtime_free_github_checks() {
let root = tempfile::tempdir().unwrap();
let initialized = lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--project-id",
"demo",
"--ci",
"github",
])
.output()
.unwrap();
assert!(initialized.status.success());
let workflow =
fs::read_to_string(root.path().join(".github/workflows/lingva-local.yml")).unwrap();
assert!(workflow.contains("lingva-installer.sh"));
assert!(workflow.contains("lingva check lingva.config.yaml"));
let remote_root = tempfile::tempdir().unwrap();
let remote = lingva(remote_root.path())
.args(["init", "--ci", "github"])
.output()
.unwrap();
assert!(!remote.status.success());
assert!(String::from_utf8_lossy(&remote.stderr).contains("only valid with --mode local"));
}
#[test]
fn local_init_and_sync_need_no_language_runtime() {
let root = tempfile::tempdir().unwrap();
let initialized = lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--sdk",
"python",
"--project-id",
"demo",
"--locale",
"en,ru",
])
.output()
.unwrap();
assert!(
initialized.status.success(),
"{}",
String::from_utf8_lossy(&initialized.stderr)
);
let config = fs::read_to_string(root.path().join("lingva.config.yaml")).unwrap();
assert!(config.contains("projectId: demo"));
assert!(root.path().join(".lingva/translations/en.json").is_file());
assert!(root.path().join(".lingva/translations/ru.json").is_file());
fs::write(
root.path().join(".lingva/translations/en.json"),
"{\"welcome\":\"Hello\"}\n",
)
.unwrap();
let drift = lingva(root.path()).arg("check").output().unwrap();
assert!(!drift.status.success());
assert!(
String::from_utf8(drift.stdout)
.unwrap()
.contains("locale drift detected")
);
assert!(lingva(root.path()).arg("sync").status().unwrap().success());
assert!(lingva(root.path()).arg("check").status().unwrap().success());
let russian = fs::read_to_string(root.path().join(".lingva/translations/ru.json")).unwrap();
assert!(russian.contains("\"welcome\": \"\""));
}
#[test]
fn react_init_generates_a_local_sdk_adapter_from_the_portable_engine() {
let root = tempfile::tempdir().unwrap();
let initialized = lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--sdk",
"react",
"--project-id",
"demo",
"--locale",
"en,ru",
])
.output()
.unwrap();
assert!(
initialized.status.success(),
"{}",
String::from_utf8_lossy(&initialized.stderr)
);
let runtime_config = fs::read_to_string(root.path().join("lingva.config.ts")).unwrap();
assert!(runtime_config.contains("generatedProjectFile: '.lingva/generated/project.ts'"));
assert!(!runtime_config.contains("import project"));
let provider = fs::read_to_string(root.path().join("src/lingva/provider.tsx")).unwrap();
assert!(provider.contains("configureLingvaReact({"));
assert!(provider.contains("...lingvaConfig"));
assert!(provider.contains("project: lingvaProject"));
let project = fs::read_to_string(root.path().join(".lingva/generated/project.ts")).unwrap();
assert!(project.contains("createLingvaResources"));
assert!(project.contains("projectId: \"demo\""));
}
#[test]
fn python_init_stays_runtime_agnostic() {
let root = tempfile::tempdir().unwrap();
assert!(
lingva(root.path())
.args([
"init",
"--mode",
"local",
"--no-auth",
"--sdk",
"python",
"--project-id",
"demo",
])
.status()
.unwrap()
.success()
);
assert!(root.path().join("lingva.config.yaml").is_file());
assert!(!root.path().join("lingva.config.ts").exists());
assert!(!root.path().join(".lingva/generated/project.ts").exists());
}
#[test]
fn pull_downloads_only_authored_values_and_persists_revision_state() {
let root = tempfile::tempdir().unwrap();
let (api_url, captured) = serve_once(
200,
&[
("content-type", "application/json"),
("x-lingva-revision", "revision-1"),
],
r#"{"messages":{"title":"Hello","fallback":"Fallback"},"sources":{"title":"en","fallback":"ru"},"variants":{"title":[{"id":"formal","text":"Welcome"}]}}"#,
);
write_remote_config(root.path(), &api_url, "en, ru");
let output = lingva(root.path())
.args(["pull", "--locale", "en", "--format", "json"])
.env("LINGVA_PUBLISH_API_TOKEN", "secret")
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let dictionary: Value = serde_json::from_str(
&fs::read_to_string(root.path().join(".lingva/translations/en.json")).unwrap(),
)
.unwrap();
assert_eq!(dictionary["title"][0]["text"], "Welcome");
assert!(dictionary.get("fallback").is_none());
let state: Value = serde_json::from_str(
&fs::read_to_string(root.path().join(".lingva/remote-state.json")).unwrap(),
)
.unwrap();
assert_eq!(state["locales"]["en"]["revision"], "revision-1");
let request = captured.recv().unwrap();
assert_eq!(request.method, "GET");
assert_eq!(request.url, "/bundles/demo/stage/latest/en.bundle.json");
assert_eq!(request.headers["authorization"], "Bearer secret");
}
#[test]
fn push_emits_semantic_payload_and_advances_state_only_after_success() {
let root = tempfile::tempdir().unwrap();
let (api_url, captured) = serve_once(
200,
&[("content-type", "application/json")],
r#"{"publishId":"revision-2","projectId":"demo","manifestKey":"manifest.json"}"#,
);
write_remote_config(root.path(), &api_url, "en");
fs::create_dir_all(root.path().join(".lingva/translations")).unwrap();
fs::write(
root.path().join(".lingva/translations/en.json"),
r#"{"title":"Hello {name}"}"#,
)
.unwrap();
let output = lingva(root.path())
.args(["push", "--locale", "en", "--format", "json"])
.env("LINGVA_PUBLISH_API_TOKEN", "secret")
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let request = captured.recv().unwrap();
assert_eq!(request.method, "POST");
let payload: Value = serde_json::from_str(&request.body).unwrap();
assert_eq!(payload["action"], "publish");
assert_eq!(payload["baseRevision"], Value::Null);
assert_eq!(
payload["bundles"]["en"]["variables"]["title"],
serde_json::json!(["name"])
);
assert!(
payload["mutationId"]
.as_str()
.unwrap()
.starts_with("sha256:")
);
let state: Value = serde_json::from_str(
&fs::read_to_string(root.path().join(".lingva/remote-state.json")).unwrap(),
)
.unwrap();
assert_eq!(state["locales"]["en"]["revision"], "revision-2");
}
#[test]
fn hosted_failure_returns_nonzero_without_advancing_state() {
let root = tempfile::tempdir().unwrap();
let (api_url, _) = serve_once(
409,
&[("content-type", "application/json")],
r#"{"error":"revision conflict"}"#,
);
write_remote_config(root.path(), &api_url, "en");
fs::create_dir_all(root.path().join(".lingva/translations")).unwrap();
fs::write(
root.path().join(".lingva/translations/en.json"),
r#"{"title":"Hello"}"#,
)
.unwrap();
let output = lingva(root.path())
.args(["push", "--locale", "en"])
.env("LINGVA_PUBLISH_API_TOKEN", "secret")
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("HTTP 409"));
assert!(!root.path().join(".lingva/remote-state.json").exists());
}
#[test]
fn multi_locale_pull_is_atomic_when_a_later_locale_conflicts() {
let root = tempfile::tempdir().unwrap();
let (api_url, _) = serve_sequence(vec![
(
200,
vec![
("content-type", "application/json"),
("x-lingva-revision", "revision-2"),
],
r#"{"messages":{"title":"Remote EN"},"sources":{"title":"en"}}"#,
),
(
200,
vec![
("content-type", "application/json"),
("x-lingva-revision", "revision-2"),
],
r#"{"messages":{"title":"Remote RU"},"sources":{"title":"ru"}}"#,
),
]);
write_remote_config(root.path(), &api_url, "en, ru");
fs::create_dir_all(root.path().join(".lingva/translations")).unwrap();
fs::write(
root.path().join(".lingva/translations/en.json"),
r#"{"title":"Base"}"#,
)
.unwrap();
fs::write(
root.path().join(".lingva/translations/ru.json"),
r#"{"title":"Local RU"}"#,
)
.unwrap();
fs::write(
root.path().join(".lingva/remote-state.json"),
r#"{"schemaVersion":1,"projectId":"demo","environment":"stage","locales":{"en":{"revision":"revision-1","base":{"title":"Base"}},"ru":{"revision":"revision-1","base":{"title":"Base"}}}}"#,
)
.unwrap();
let output = lingva(root.path())
.arg("pull")
.env("LINGVA_PUBLISH_API_TOKEN", "secret")
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("conflicts in locale \"ru\""));
assert_eq!(
fs::read_to_string(root.path().join(".lingva/translations/en.json")).unwrap(),
r#"{"title":"Base"}"#
);
let state: Value = serde_json::from_str(
&fs::read_to_string(root.path().join(".lingva/remote-state.json")).unwrap(),
)
.unwrap();
assert_eq!(state["locales"]["en"]["revision"], "revision-1");
}
#[test]
fn push_can_invalidate_after_publish_with_one_consistent_token() {
let root = tempfile::tempdir().unwrap();
let (api_url, captured) = serve_sequence(vec![
(
200,
vec![("content-type", "application/json")],
r#"{"publishId":"revision-2","projectId":"demo","manifestKey":"manifest.json"}"#,
),
(
200,
vec![("content-type", "application/json")],
r#"{"invalidationId":"invalidate-1"}"#,
),
]);
write_remote_config(root.path(), &api_url, "en");
fs::create_dir_all(root.path().join(".lingva/translations")).unwrap();
fs::write(
root.path().join(".lingva/translations/en.json"),
r#"{"title":"Hello"}"#,
)
.unwrap();
let output = lingva(root.path())
.args([
"push",
"--invalidate",
"--reason",
"release",
"--format",
"json",
])
.env("LINGVA_PUBLISH_API_TOKEN", "secret")
.env("HOME", root.path().join("home"))
.output()
.unwrap();
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let result: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(result["publish"]["publishId"], "revision-2");
assert_eq!(result["invalidation"]["invalidationId"], "invalidate-1");
let publish = captured.recv().unwrap();
let invalidate = captured.recv().unwrap();
assert_eq!(publish.headers["authorization"], "Bearer secret");
assert_eq!(invalidate.headers["authorization"], "Bearer secret");
let payload: Value = serde_json::from_str(&invalidate.body).unwrap();
assert_eq!(payload["action"], "invalidate");
assert_eq!(payload["reason"], "release");
}