mod api;
mod auth;
mod config;
mod credentials;
mod local;
mod state;
use std::{
env, fs,
io::{self, Write},
path::{Path, PathBuf},
process::ExitCode,
};
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand, ValueEnum};
use serde::Serialize;
use serde_json::{Value, json};
use api::{LingvaApi, PlatformProject};
use auth::{DEFAULT_AUTH_PORT, DEFAULT_AUTH_SCOPES, DEFAULT_PLATFORM_URL, LoginOptions};
use config::{
Config, ConfigDocument, DEFAULT_API_KEY_ENV, DEFAULT_CONFIG_FILE, DEFAULT_ENVIRONMENT,
DEFAULT_TRANSLATION_FILES, PUBLISH_TOKEN_ENV,
};
use local::{Dictionary, read_dictionary, set_dotted, write_dictionary};
use state::{RemoteState, merge_remote};
const EXIT_SUCCESS: u8 = 0;
const EXIT_FAILURE: u8 = 1;
const GITHUB_LATEST_RELEASE_URL: &str =
"https://api.github.com/repos/usausoft/lingva/releases/latest";
const INSTALL_COMMAND: &str = "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/usausoft/lingva/releases/latest/download/lingva-installer.sh | sh";
#[derive(Debug, Parser)]
#[command(name = "lingva", version, disable_version_flag = true, about = "Lingva localization CLI", long_about = None)]
struct Cli {
#[arg(short = 'v', long = "version", action = clap::ArgAction::Version)]
_version: Option<bool>,
#[command(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Auth {
#[command(subcommand)]
command: AuthCommand,
},
Init(InitArgs),
Update(OutputArgs),
Sync(SyncArgs),
Check(ConfigArgs),
Pull(PullArgs),
Push(PushArgs),
}
#[derive(Debug, Subcommand)]
enum AuthCommand {
Login(AuthLoginArgs),
UseKey(AuthKeyArgs),
Status(OutputArgs),
Logout(OutputArgs),
}
#[derive(Debug, Args)]
struct AuthLoginArgs {
#[arg(long, default_value = DEFAULT_PLATFORM_URL)]
platform_url: String,
#[arg(long)]
issuer_url: Option<String>,
#[arg(long)]
authorize_url: Option<String>,
#[arg(long)]
token_url: Option<String>,
#[arg(long)]
client_id: Option<String>,
#[arg(long, default_value = DEFAULT_AUTH_SCOPES)]
scope: String,
#[arg(long, default_value_t = DEFAULT_AUTH_PORT)]
port: u16,
#[arg(long)]
no_browser: bool,
}
#[derive(Debug, Args)]
struct AuthKeyArgs {
#[arg(long, env = "LINGVA_API_KEY")]
api_key: String,
#[arg(long)]
api_url: Option<String>,
#[arg(long)]
project_id: Option<String>,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Debug, Clone, Args)]
struct OutputArgs {
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum OutputFormat {
Text,
Json,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum Mode {
Remote,
Local,
}
#[derive(Debug, Clone, Copy, ValueEnum, PartialEq, Eq)]
enum Sdk {
Vanilla,
React,
Next,
Angular,
Vue,
Svelte,
Python,
Swift,
Kotlin,
Flutter,
}
#[derive(Debug, Args)]
struct InitArgs {
#[arg(long, default_value = DEFAULT_CONFIG_FILE)]
config_file: PathBuf,
#[arg(long)]
project_id: Option<String>,
#[arg(long)]
environment: Option<String>,
#[arg(long, value_delimiter = ',')]
locale: Vec<String>,
#[arg(long, value_enum, default_value_t = Mode::Remote)]
mode: Mode,
#[arg(long)]
no_auth: bool,
#[arg(long)]
non_interactive: bool,
#[arg(long)]
platform_url: Option<String>,
#[arg(long, value_enum)]
sdk: Option<Sdk>,
#[arg(long)]
default_locale: Option<String>,
#[arg(long)]
fallback_locale: Option<String>,
#[arg(long, default_value = DEFAULT_TRANSLATION_FILES)]
translation_files: String,
#[arg(long)]
api_url: Option<String>,
#[arg(long, default_value = DEFAULT_API_KEY_ENV)]
api_key_env: String,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Debug, Args)]
struct ConfigArgs {
#[arg(default_value = DEFAULT_CONFIG_FILE)]
config_file: PathBuf,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Debug, Args)]
struct SyncArgs {
#[arg(default_value = DEFAULT_CONFIG_FILE)]
config_file: PathBuf,
#[arg(long)]
dry_run: bool,
#[arg(long)]
check: bool,
#[command(flatten)]
output: OutputArgs,
}
#[derive(Debug, Args)]
struct PullArgs {
#[arg(default_value = DEFAULT_CONFIG_FILE)]
config_file: PathBuf,
#[arg(long, value_delimiter = ',')]
locale: Vec<String>,
#[arg(long)]
output: Option<String>,
#[arg(long)]
no_summary: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
#[derive(Debug, Args)]
struct PushArgs {
#[arg(default_value = DEFAULT_CONFIG_FILE)]
config_file: PathBuf,
#[arg(long, value_delimiter = ',')]
locale: Vec<String>,
#[arg(long)]
api_url: Option<String>,
#[arg(long)]
auth_token: Option<String>,
#[arg(long)]
output_dir: Option<PathBuf>,
#[arg(long)]
force: bool,
#[arg(long)]
no_summary: bool,
#[arg(long)]
no_metadata: bool,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
format: OutputFormat,
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => ExitCode::from(code),
Err(error) => {
eprintln!("Error: {error:#}");
ExitCode::from(EXIT_FAILURE)
}
}
}
fn run(cli: Cli) -> Result<u8> {
match cli.command {
Command::Auth { command } => run_auth(command),
Command::Init(args) => run_init(args),
Command::Update(args) => run_update(args),
Command::Sync(args) => run_sync(
&args.config_file,
!args.dry_run && !args.check,
args.check,
args.output.format,
),
Command::Check(args) => run_sync(&args.config_file, false, true, args.output.format),
Command::Pull(args) => run_pull(args),
Command::Push(args) => run_push(args),
}
}
fn run_auth(command: AuthCommand) -> Result<u8> {
let path = credentials::default_path()?;
match command {
AuthCommand::Login(args) => {
let api = LingvaApi::new()?;
let credentials = auth::login(
&api,
LoginOptions {
platform_url: &args.platform_url,
issuer_url: args.issuer_url.as_deref(),
authorize_url: args.authorize_url.as_deref(),
token_url: args.token_url.as_deref(),
client_id: args.client_id.as_deref(),
scopes: &args.scope,
port: args.port,
open_browser: !args.no_browser,
},
&path,
)?;
println!(
"Lingva auth login completed{}.",
credentials
.expires_at
.as_ref()
.map(|value| format!("; token expires {value}"))
.unwrap_or_default()
);
Ok(EXIT_SUCCESS)
}
AuthCommand::UseKey(args) => {
let credentials =
credentials::store_api_key(&path, &args.api_key, args.api_url, args.project_id)?;
emit(
&json!({"credentialsFile": path, "apiKeyPreview": credentials.api_key_preview, "projectId": credentials.project_id}),
args.output.format,
format!(
"Lingva auth project API key stored ({}).",
credentials
.api_key_preview
.as_deref()
.unwrap_or("stored-key")
),
)?;
Ok(EXIT_SUCCESS)
}
AuthCommand::Status(args) => {
let credentials = credentials::load(&path)?;
let authenticated = credentials::token(credentials.as_ref()).is_some();
let method = credentials.as_ref().map(|value| {
if value.api_key.is_some() {
"api-key"
} else {
"oauth"
}
});
emit(
&json!({"authenticated": authenticated, "authMethod": method, "credentialsFile": path, "apiKeyPreview": credentials.as_ref().and_then(|value| value.api_key_preview.clone()), "projectId": credentials.as_ref().and_then(|value| value.project_id.clone()), "expiresAt": credentials.as_ref().and_then(|value| value.expires_at.clone())}),
args.format,
format!(
"Lingva auth status: {}",
if authenticated {
"authenticated"
} else {
"not authenticated"
}
),
)?;
Ok(if authenticated {
EXIT_SUCCESS
} else {
EXIT_FAILURE
})
}
AuthCommand::Logout(args) => {
credentials::clear(&path)?;
emit(
&json!({"credentialsFile": path, "authenticated": false}),
args.format,
"Lingva auth logout completed.".into(),
)?;
Ok(EXIT_SUCCESS)
}
}
}
fn run_init(args: InitArgs) -> Result<u8> {
if args.no_auth && args.mode != Mode::Local {
bail!("--no-auth is only valid with --mode local.");
}
let credentials_path = credentials::default_path()?;
let credentials = if args.no_auth {
None
} else {
credentials::load(&credentials_path)?
};
if !args.no_auth && credentials::resolve_token(&credentials_path)?.is_none() {
bail!(
"Lingva init requires authentication. Run \"lingva auth login\" or use --mode local --no-auth."
);
}
let mut project_id = args.project_id.or_else(|| {
credentials
.as_ref()
.and_then(|value| value.project_id.clone())
});
let mut api_url = args
.api_url
.or_else(|| credentials.as_ref().and_then(|value| value.api_url.clone()));
let mut environment = args
.environment
.unwrap_or_else(|| DEFAULT_ENVIRONMENT.into());
let mut locales = unique_non_empty(args.locale);
let mut default_locale = args.default_locale;
if args.mode == Mode::Remote
&& credentials
.as_ref()
.and_then(|value| value.access_token.as_ref())
.is_some()
&& (project_id.is_none() || api_url.is_none())
{
let stored = credentials.as_ref().unwrap();
let platform_url = args
.platform_url
.as_deref()
.or(stored.platform_url.as_deref())
.unwrap_or(DEFAULT_PLATFORM_URL);
let api = LingvaApi::new()?;
let projects = api.list_projects(platform_url, stored.access_token.as_deref().unwrap())?;
let candidates = project_id.as_ref().map_or(projects.clone(), |id| {
projects
.into_iter()
.filter(|project| &project.project_id == id)
.collect()
});
let project = select_project(candidates, args.non_interactive)?;
project_id = Some(project.project_id.clone());
locales = project.locales.clone();
default_locale = Some(project.default_locale.clone());
let selected_environment = project
.environments
.iter()
.find(|item| item.environment_id == environment && item.publish_api_url.is_some())
.or_else(|| {
project
.environments
.iter()
.find(|item| item.publish_api_url.is_some())
})
.context(format!(
"Project \"{}\" has no configured remote environment.",
project.project_id
))?;
environment = selected_environment.environment_id.clone();
api_url = selected_environment.publish_api_url.clone();
}
if args.mode == Mode::Remote && (project_id.is_none() || api_url.is_none()) {
bail!(
"Remote init requires --project-id and --api-url when project discovery is unavailable."
);
}
if locales.is_empty() {
locales = vec!["en".into(), "ru".into()];
}
let project_id = project_id.unwrap_or_else(|| "lingva-app".into());
let default_locale = default_locale.unwrap_or_else(|| locales[0].clone());
let fallback_locale = args
.fallback_locale
.unwrap_or_else(|| default_locale.clone());
let path = if args.config_file.is_absolute() {
args.config_file
} else {
env::current_dir()?.join(args.config_file)
};
if path.exists() && !args.force {
bail!(
"Lingva config \"{}\" already exists; pass --force to replace it.",
path.display()
);
}
let document = ConfigDocument {
project_id,
environment,
locales: locales.clone(),
default_locale: Some(default_locale),
fallback_locale: Some(fallback_locale),
translation_files: args.translation_files.clone(),
api_key_env: args.api_key_env,
api_url,
api_key: None,
};
let mut files = std::iter::once(path.clone())
.chain(locales.iter().map(|locale| {
let candidate = PathBuf::from(args.translation_files.replace("{locale}", locale));
if candidate.is_absolute() {
candidate
} else {
path.parent().unwrap_or(Path::new(".")).join(candidate)
}
}))
.collect::<Vec<_>>();
let sdk_files = create_sdk_files(
path.parent().unwrap_or(Path::new(".")),
args.sdk,
args.mode,
&document,
)?;
files.extend(sdk_files.iter().map(|(file, _)| file.clone()));
if !args.force {
if let Some(existing) = sdk_files
.iter()
.map(|(file, _)| file)
.find(|file| file.exists())
{
bail!(
"Lingva SDK adapter \"{}\" already exists; pass --force to replace it.",
existing.display()
);
}
}
if !args.dry_run {
config::write_document(&path, &document)?;
for file in files.iter().skip(1).take(locales.len()) {
if !file.exists() {
write_dictionary(file, &Dictionary::new())?;
}
}
for (file, content) in &sdk_files {
if let Some(parent) = file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(file, content)?;
}
let ignore = path
.parent()
.unwrap_or(Path::new("."))
.join(".lingva/.gitignore");
if !ignore.exists() {
fs::create_dir_all(ignore.parent().unwrap())?;
fs::write(ignore, "remote-state.json\n")?;
}
}
let payload = json!({"configFile": path, "files": files, "written": !args.dry_run, "sdk": args.sdk.map(|value| format!("{value:?}").to_lowercase())});
emit(
&payload,
args.output.format,
files
.iter()
.map(|file| file.display().to_string())
.collect::<Vec<_>>()
.join("\n"),
)?;
Ok(EXIT_SUCCESS)
}
fn create_sdk_files(
root: &Path,
sdk: Option<Sdk>,
mode: Mode,
config: &ConfigDocument,
) -> Result<Vec<(PathBuf, String)>> {
let Some(sdk) = sdk else {
return Ok(Vec::new());
};
if matches!(sdk, Sdk::Python | Sdk::Swift | Sdk::Kotlin | Sdk::Flutter) {
return Ok(Vec::new());
}
let local = mode == Mode::Local;
let mut files = Vec::new();
if local {
files.push((
root.join(".lingva/generated/project.ts"),
render_generated_project(config),
));
}
files.push((
root.join("lingva.config.ts"),
render_runtime_config(config, mode)?,
));
if let Some((relative_path, content)) = render_framework_adapter(sdk, local) {
files.push((root.join(relative_path), content));
}
Ok(files)
}
fn render_generated_project(config: &ConfigDocument) -> String {
let imports = config
.locales
.iter()
.map(|locale| {
format!(
"import {} from '../translations/{}.json';",
locale_identifier(locale),
locale
)
})
.collect::<Vec<_>>()
.join("\n");
let entries = config
.locales
.iter()
.map(|locale| {
format!(
" {}: {},",
serde_json::to_string(locale).unwrap(),
locale_identifier(locale)
)
})
.collect::<Vec<_>>()
.join("\n");
format!(
"import {{ createLingvaResources, defineLingvaProject }} from '@lingva/core';\n{imports}\n\nexport const resources = createLingvaResources({{\n{entries}\n}});\n\nexport const project = defineLingvaProject({{\n projectId: {},\n resources,\n defaultLocale: {},\n fallbackLocale: {},\n environment: {},\n}});\n\nexport default project;\n",
serde_json::to_string(&config.project_id).unwrap(),
serde_json::to_string(
config
.default_locale
.as_deref()
.unwrap_or(&config.locales[0])
)
.unwrap(),
serde_json::to_string(
config
.fallback_locale
.as_deref()
.unwrap_or(&config.locales[0])
)
.unwrap(),
serde_json::to_string(&config.environment).unwrap(),
)
}
fn render_runtime_config(config: &ConfigDocument, mode: Mode) -> Result<String> {
let project_id = serde_json::to_string(&config.project_id)?;
let environment = serde_json::to_string(&config.environment)?;
let locales = serde_json::to_string(&config.locales)?;
let default_locale = serde_json::to_string(
config
.default_locale
.as_deref()
.unwrap_or(&config.locales[0]),
)?;
let fallback_locale = serde_json::to_string(
config
.fallback_locale
.as_deref()
.unwrap_or(&config.locales[0]),
)?;
let translation_files = serde_json::to_string(&config.translation_files)?;
if mode == Mode::Local {
return Ok(format!(
"import {{ defineLingvaConfig }} from '@lingva/core';\n\nconst lingvaConfig = defineLingvaConfig({{\n projectId: {project_id},\n locales: {locales},\n defaultLocale: {default_locale},\n locale: {default_locale},\n fallbackLocale: {fallback_locale},\n translationFiles: {translation_files},\n generatedProjectFile: '.lingva/generated/project.ts',\n metadataFile: '.lingva/metadata.json',\n delivery: {{ mode: 'local', path: {translation_files} }},\n}});\n\nexport default lingvaConfig;\n"
));
}
let api_url = config
.api_url
.as_deref()
.context("Remote SDK setup requires apiUrl.")?;
let bundle_url = format!(
"{}/bundles/{}/{}/latest/{{locale}}.bundle.json",
api_url.trim_end_matches('/'),
config.project_id,
config.environment
);
let api_key_access = format!("[{}]", serde_json::to_string(&config.api_key_env)?);
Ok(format!(
"import {{ defineLingvaConfig }} from '@lingva/core';\n\nconst lingvaConfig = defineLingvaConfig({{\n projectId: {project_id},\n environment: {environment},\n locales: {locales},\n defaultLocale: {default_locale},\n locale: {default_locale},\n fallbackLocale: {fallback_locale},\n apiUrl: {},\n apiKey: process.env{api_key_access},\n delivery: {{ mode: 'cdn', url: {} }},\n}});\n\nexport default lingvaConfig;\n",
serde_json::to_string(api_url)?,
serde_json::to_string(&bundle_url)?,
))
}
fn render_framework_adapter(sdk: Sdk, local: bool) -> Option<(&'static str, String)> {
let project_import = if local {
"import lingvaProject from '../../.lingva/generated/project';\n"
} else {
""
};
let adapter_input = if local {
"{\n ...lingvaConfig,\n project: lingvaProject,\n}"
} else {
"lingvaConfig"
};
match sdk {
Sdk::React => Some((
"src/lingva/provider.tsx",
format!(
"'use client';\n\nimport type {{ PropsWithChildren }} from 'react';\nimport {{ configureLingvaReact, LingvaProvider }} from '@lingva/react';\nimport lingvaConfig from '../../lingva.config';\n{project_import}\nconfigureLingvaReact({adapter_input});\n\n/** App-level Lingva provider generated by `lingva init --sdk react`. */\nexport function AppLingvaProvider({{ children }}: PropsWithChildren) {{\n return <LingvaProvider>{{children}}</LingvaProvider>;\n}}\n"
),
)),
Sdk::Next => Some((
"src/lingva/provider.tsx",
format!(
"'use client';\n\nimport {{ createLingvaNextProvider }} from '@lingva/next';\nimport lingvaConfig from '../../lingva.config';\n{project_import}\n/** App-level Lingva provider generated by `lingva init --sdk next`. */\nexport const AppLingvaProvider = createLingvaNextProvider({{\n config: lingvaConfig,\n{} displayName: 'AppLingvaProvider',\n}});\n",
if local {
" project: lingvaProject,\n"
} else {
""
}
),
)),
Sdk::Angular => Some((
"src/lingva/providers.ts",
format!(
"import {{ provideLingvaAngular }} from '@lingva/angular';\nimport lingvaConfig from '../../lingva.config';\n{project_import}\n/** Registers the generated Lingva configuration at the Angular application boundary. */\nexport function provideAppLingva() {{\n return provideLingvaAngular({adapter_input});\n}}\n"
),
)),
Sdk::Vue => Some((
"src/lingva/index.ts",
format!(
"import {{ createLingvaVue }} from '@lingva/vue';\nimport lingvaConfig from '../../lingva.config';\n{}\n/** Vue plugin generated by `lingva init --sdk vue`. */\nexport const appLingva = createLingvaVue{}({adapter_input});\n",
if local {
"import lingvaProject, { type resources } from '../../.lingva/generated/project';"
} else {
""
},
if local { "<typeof resources>" } else { "" }
),
)),
Sdk::Svelte => Some((
"src/lib/lingva.ts",
format!(
"import {{ createLingvaSvelte }} from '@lingva/svelte';\nimport lingvaConfig from '../../lingva.config';\n{}\n/** Svelte stores generated by `lingva init --sdk svelte`. */\nexport const appLingva = createLingvaSvelte{}({adapter_input});\n",
if local {
"import lingvaProject, { type resources } from '../../.lingva/generated/project';"
} else {
""
},
if local { "<typeof resources>" } else { "" }
),
)),
Sdk::Vanilla | Sdk::Python | Sdk::Swift | Sdk::Kotlin | Sdk::Flutter => None,
}
}
fn locale_identifier(locale: &str) -> String {
let suffix = locale
.chars()
.map(|value| {
if value.is_ascii_alphanumeric() {
value
} else {
'_'
}
})
.collect::<String>();
format!("locale_{suffix}")
}
fn select_project(
projects: Vec<PlatformProject>,
non_interactive: bool,
) -> Result<PlatformProject> {
if projects.is_empty() {
bail!("No matching Lingva project is available to the authenticated user.");
}
if projects.len() == 1 {
return Ok(projects.into_iter().next().unwrap());
}
if non_interactive || !std::io::IsTerminal::is_terminal(&io::stdin()) {
bail!(
"Multiple Lingva projects are available. Pass --project-id ({}).",
projects
.iter()
.map(|project| project.project_id.as_str())
.collect::<Vec<_>>()
.join(", ")
);
}
println!("Choose a Lingva project:");
for (index, project) in projects.iter().enumerate() {
println!("{}. {} ({})", index + 1, project.name, project.project_id);
}
print!("Project number: ");
io::stdout().flush()?;
let mut choice = String::new();
io::stdin().read_line(&mut choice)?;
let index = choice
.trim()
.parse::<usize>()
.context("Select a valid project number.")?;
projects
.into_iter()
.nth(index.saturating_sub(1))
.context("Select a valid project number.")
}
fn run_sync(config_file: &Path, write: bool, check: bool, format: OutputFormat) -> Result<u8> {
let config = Config::load(config_file)?;
let result = local::sync(&config, write)?;
let state = if write { "updated" } else { "planned" };
let mut lines = vec![format!(
"Lingva sync: {state} local translations for {}",
result.locales.join(", ")
)];
lines.extend(result.files.iter().map(|file| format!("- {}: {} ({} missing keys, {} missing variants, {} extra variants, {} variant metadata mismatches)",
file.locale, file.file_path, file.drift.missing_keys, file.drift.missing_variants, file.drift.extra_variants, file.drift.variant_metadata_mismatches)));
if check {
lines.push(format!(
"- check: {}",
if result.has_drift {
"failed (locale drift detected)"
} else {
"passed"
}
));
}
emit(&result, format, lines.join("\n"))?;
Ok(if check && result.has_drift {
EXIT_FAILURE
} else {
EXIT_SUCCESS
})
}
fn run_pull(args: PullArgs) -> Result<u8> {
let config = Config::load(&args.config_file)?;
let locales = if args.locale.is_empty() {
config.locales.clone()
} else {
unique_non_empty(args.locale)
};
validate_locales(&config, &locales)?;
let credentials_path = credentials::default_path()?;
let token = resolve_token(&config, None, &credentials_path)?;
let api = LingvaApi::new()?;
let mut state = RemoteState::load(&config);
let mut writes = Vec::new();
for locale in &locales {
let (bundle, revision) = api.pull_bundle(&config, locale, token.as_deref())?;
let remote = bundle_dictionary(&bundle, locale)?;
let template = args.output.as_deref().unwrap_or(&config.translation_files);
let candidate = PathBuf::from(template.replace("{locale}", locale));
let path = if candidate.is_absolute() {
candidate
} else {
config
.path
.parent()
.unwrap_or(Path::new("."))
.join(candidate)
};
let local = read_dictionary(&path)?;
let base = state
.locales
.get(locale)
.map(|value| &value.base)
.cloned()
.unwrap_or_default();
let merged = merge_remote(locale, &base, &local, &remote)?;
writes.push((locale.clone(), path, merged, revision, remote));
}
for (locale, path, merged, revision, remote) in &writes {
write_dictionary(path, merged)?;
state.set_locale(locale, revision, remote);
}
state.save(&config)?;
let files = writes
.iter()
.map(|(_, path, _, _, _)| path.display().to_string())
.collect::<Vec<_>>();
let text = [
(!args.no_summary).then(|| {
format!(
"Lingva pull: wrote {} hosted locale file(s) for {}",
files.len(),
locales.join(", ")
)
}),
Some(
files
.iter()
.map(|file| format!("- {file}"))
.collect::<Vec<_>>()
.join("\n"),
),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("\n");
emit(
&json!({"configFile": config.path, "locales": locales, "files": files}),
args.format,
text,
)?;
Ok(EXIT_SUCCESS)
}
fn run_push(args: PushArgs) -> Result<u8> {
let mut config = Config::load(&args.config_file)?;
if args.api_url.is_some() {
config.api_url = args.api_url;
}
let locales = if args.locale.is_empty() {
config.locales.clone()
} else {
unique_non_empty(args.locale)
};
validate_locales(&config, &locales)?;
let credentials_path = credentials::default_path()?;
let token = resolve_token(&config, args.auth_token, &credentials_path)?;
let mut state = RemoteState::load(&config);
let base_revision = state.base_revision(&locales, args.force)?;
let api = LingvaApi::new()?;
let (result, resources) = api.push(
&config,
&locales,
token.as_deref(),
base_revision,
args.force,
)?;
if let Some(output_dir) = args.output_dir {
fs::create_dir_all(&output_dir)?;
fs::write(
output_dir.join("publish-response.json"),
format!("{}\n", serde_json::to_string_pretty(&result)?),
)?;
}
let revision = result
.get("publishId")
.and_then(Value::as_str)
.context("Lingva hosted push response has no publishId revision.")?;
for locale in &locales {
state.set_locale(locale, revision, &resources[locale]);
}
state.save(&config)?;
let text = if args.no_summary {
String::new()
} else {
format!(
"Hosted publish: {} -> {}",
result
.get("projectId")
.and_then(Value::as_str)
.unwrap_or(&config.project_id),
result
.get("manifestKey")
.and_then(Value::as_str)
.unwrap_or("complete")
)
};
emit(&result, args.format, text)?;
Ok(EXIT_SUCCESS)
}
fn run_update(args: OutputArgs) -> Result<u8> {
let api = reqwest::blocking::Client::builder()
.user_agent(format!("lingva/{}", env!("CARGO_PKG_VERSION")))
.build()?;
let payload = api
.get(GITHUB_LATEST_RELEASE_URL)
.send()?
.error_for_status()?
.json::<Value>()?;
let latest = payload
.get("tag_name")
.and_then(Value::as_str)
.context("GitHub did not return the latest Lingva CLI version.")?
.trim_start_matches("cli/v")
.trim_start_matches('v');
let current = env!("CARGO_PKG_VERSION");
let update_available = semver::Version::parse(latest)? > semver::Version::parse(current)?;
emit(
&json!({"currentVersion": current, "latestVersion": latest, "updateAvailable": update_available, "installCommand": update_available.then_some(INSTALL_COMMAND)}),
args.format,
if update_available {
format!("Lingva CLI update available: {current} -> {latest}\nRun: {INSTALL_COMMAND}")
} else {
format!("Lingva CLI is current ({current}).")
},
)?;
Ok(EXIT_SUCCESS)
}
fn bundle_dictionary(bundle: &Value, locale: &str) -> Result<Dictionary> {
let messages = bundle
.get("messages")
.and_then(Value::as_object)
.context(format!(
"Lingva bundle for locale \"{locale}\" has no messages object."
))?;
let sources = bundle.get("sources").and_then(Value::as_object);
let variants = bundle.get("variants").and_then(Value::as_object);
let mut result = Dictionary::new();
for (key, value) in messages {
if sources
.and_then(|items| items.get(key))
.and_then(Value::as_str)
.is_some_and(|source| source != locale)
{
continue;
}
set_dotted(
&mut result,
key,
variants
.and_then(|items| items.get(key))
.cloned()
.unwrap_or_else(|| value.clone()),
)?;
}
Ok(result)
}
fn resolve_token(
config: &Config,
explicit: Option<String>,
credentials_path: &Path,
) -> Result<Option<String>> {
Ok(explicit
.filter(|value| !value.is_empty())
.or_else(|| {
env::var(PUBLISH_TOKEN_ENV)
.ok()
.filter(|value| !value.is_empty())
})
.or_else(|| config.api_key.clone())
.or(credentials::resolve_token(credentials_path)?))
}
fn validate_locales(config: &Config, locales: &[String]) -> Result<()> {
let missing = locales
.iter()
.filter(|locale| !config.locales.contains(locale))
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
bail!(
"Requested locales are not present in config.locales: {}.",
missing.join(", ")
);
}
Ok(())
}
fn unique_non_empty(values: Vec<String>) -> Vec<String> {
values
.into_iter()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.fold(Vec::new(), |mut result, value| {
if !result.contains(&value) {
result.push(value);
}
result
})
}
fn emit(payload: &impl Serialize, format: OutputFormat, text: String) -> Result<()> {
println!(
"{}",
if format == OutputFormat::Json {
serde_json::to_string_pretty(payload)?
} else {
text
}
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bundle_dictionary_preserves_variants_and_ignores_fallbacks() {
let bundle = json!({
"messages": {"welcome":"Hello","fallback":"Fallback"},
"sources": {"welcome":"en","fallback":"ru"},
"variants": {"welcome":[{"id":"formal","text":"Welcome"}]}
});
let dictionary = bundle_dictionary(&bundle, "en").unwrap();
assert!(dictionary.get("fallback").is_none());
assert_eq!(dictionary["welcome"][0]["id"], "formal");
}
}