From acb75d748ad917c30d0ad2843cf41cefbc196165 Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 06:57:48 +0800 Subject: [PATCH 1/4] ci: check formatting, lints, and CLI tests --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..146069f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the pinned Rust toolchain + shell: bash + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("mise.toml", "rb"))["tools"]["rust"]["version"])') + rustup toolchain install "$version" --profile minimal --component clippy --component rustfmt + rustup override set "$version" + - name: Check formatting + run: cargo fmt --all --check + - name: Run tests + run: cargo test --locked + - name: Check lints + run: cargo clippy --locked --all-targets --all-features -- -D warnings + - name: Check package contents + run: cargo package --locked --list From 92fb1063197ec555173dfc7a79c8e08f5002be3e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 06:59:23 +0800 Subject: [PATCH 2/4] ci: verify and retain the source package without publishing --- .github/workflows/ci.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 146069f..aaff6ec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,12 @@ jobs: run: cargo test --locked - name: Check lints run: cargo clippy --locked --all-targets --all-features -- -D warnings - - name: Check package contents - run: cargo package --locked --list + - name: Verify source package + run: cargo package --locked + - name: Keep the checked source package + uses: actions/upload-artifact@v4 + with: + name: lmm-cli-source-${{ github.sha }} + path: target/package/*.crate + retention-days: 7 + if-no-files-found: error From adde45f945b91fe6c7a44a752b41f014a84c2e1d Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Mon, 12 Oct 2026 07:25:14 +0800 Subject: [PATCH 3/4] feat: localize CLI messages and preserve language-independent JSON --- .github/workflows/ci.yml | 40 +++++- AGENTS.md | 5 + Cargo.toml | 2 +- locales/en.json | 122 +++++++++++++++++ locales/zh-CN.json | 122 +++++++++++++++++ scripts/check-i18n.py | 61 +++++++++ src/catalog.rs | 24 ++-- src/discovery.rs | 17 ++- src/i18n.rs | 207 +++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 274 ++++++++++++++++++++++----------------- src/oauth.rs | 51 +++++--- src/oauth/callback.rs | 5 +- src/plan.rs | 15 ++- src/provider.rs | 14 ++ src/restore.rs | 9 +- tests/i18n.rs | 181 ++++++++++++++++++++++++++ 17 files changed, 980 insertions(+), 170 deletions(-) create mode 100644 locales/en.json create mode 100644 locales/zh-CN.json create mode 100644 scripts/check-i18n.py create mode 100644 src/i18n.rs create mode 100644 tests/i18n.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaff6ec..8db0f00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,28 @@ concurrency: cancel-in-progress: true jobs: + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install the pinned formatter + shell: bash + run: | + version=$(python3 -c 'import tomllib; print(tomllib.load(open("mise.toml", "rb"))["tools"]["rust"]["version"])') + rustup toolchain install "$version" --profile minimal --component rustfmt + rustup override set "$version" + - name: Check formatting + run: | + cargo fmt --all + git diff --exit-code > "$RUNNER_TEMP/rustfmt.patch" + - name: Keep formatting corrections on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: rustfmt-patch + path: ${{ runner.temp }}/rustfmt.patch + retention-days: 7 + if-no-files-found: ignore check: runs-on: ubuntu-latest steps: @@ -21,16 +43,24 @@ jobs: shell: bash run: | version=$(python3 -c 'import tomllib; print(tomllib.load(open("mise.toml", "rb"))["tools"]["rust"]["version"])') - rustup toolchain install "$version" --profile minimal --component clippy --component rustfmt + rustup toolchain install "$version" --profile minimal --component clippy rustup override set "$version" - - name: Check formatting - run: cargo fmt --all --check + - name: Check translations + run: python3 scripts/check-i18n.py - name: Run tests - run: cargo test --locked + run: cargo test --locked 2>&1 | tee "$RUNNER_TEMP/tests.log" - name: Check lints - run: cargo clippy --locked --all-targets --all-features -- -D warnings + run: cargo clippy --locked --all-targets --all-features -- -D warnings 2>&1 | tee "$RUNNER_TEMP/clippy.log" - name: Verify source package run: cargo package --locked + - name: Keep check logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: check-logs + path: ${{ runner.temp }}/*.log + retention-days: 7 + if-no-files-found: ignore - name: Keep the checked source package uses: actions/upload-artifact@v4 with: diff --git a/AGENTS.md b/AGENTS.md index 67a1e02..b4cf7d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,3 +16,8 @@ fixtures instead of real account, system configuration or production writes. Run fmt, clippy and the complete CLI tests locally. Report the tested commit and skipped checks. A source change does not authorize publishing a crate, release or production deployment. + +Keep user-facing text in locales/en.json and locales/zh-CN.json, not Rust source. +Use stable message keys and named placeholders. Do not translate command names, +JSON field names, identifiers or protocol values. Run scripts/check-i18n.py and +read docs/i18n.md when adding messages. Keep both README versions in sync. diff --git a/Cargo.toml b/Cargo.toml index aeaf9ce..25ecdfe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ homepage = "https://api.lmm.best" readme = "README.md" keywords = ["lmm", "cli", "oauth", "ai"] categories = ["command-line-utilities"] -include = ["Cargo.toml", "Cargo.lock", "README.md", "LICENSE", "src/**", "tests/**", "docs/**", "NOTICE", "third_party/**", "scripts/*.py"] +include = ["Cargo.toml", "Cargo.lock", "README.md", "README.zh-CN.md", "locales/**", "mise.toml", "LICENSE", "src/**", "tests/**", "docs/**", "NOTICE", "third_party/**", "scripts/*.py"] [lib] name = "lmm" diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..4c6d793 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,122 @@ +{ + "arg.activate": "Request adding and selecting LMM; this does not mean it has been applied", + "arg.add_only": "Add LMM without changing the current selection", + "arg.all": "Include only installed, supported targets; updates also require management approval", + "arg.dry_run": "Create a plan without applying changes", + "arg.fix": "Request approved LMM repairs without bypassing capability checks", + "arg.instance": "Explicit local instance root; remote and Docker instances are not scanned", + "arg.issuer": "Trusted LMM HTTPS origin; set explicitly for a self-hosted server", + "arg.json": "Write JSON without CLI prompts or configuration contents", + "arg.lang": "Display language: en or zh-CN; overrides LMM_LANG and locale variables", + "arg.no_browser": "Print the login URL; the browser must still reach this machine through loopback", + "arg.non_interactive": "Do not wait for input when a target or authorization is missing", + "arg.query": "Search text; omit to list all entries", + "arg.report": "Create a shareable report without paths or configuration contents", + "arg.software": "Exact catalog ID, such as astrbot or cc-switch", + "arg.timeout": "Maximum wait for the browser callback, in seconds", + "arg.yes": "Approve the displayed scope, not account changes, provider switches, or paid tests", + "auth.already_logged_in": "The CLI is already signed in; run lmm logout before switching accounts. Application grants will not be transferred", + "auth.busy": "Another LMM authorization operation is running, or a secure per-user lock could not be created", + "auth.callback": "Could not bind the local OAuth callback port", + "auth.credential_store": "Could not access the system vault. Unlock Keychain, Credential Manager, or Secret Service; plain-text files will not be used", + "auth.denied": "Authorization was cancelled or denied", + "auth.invalid_issuer": "issuer must be a canonical HTTPS domain origin without a port, path, query, or credentials", + "auth.invalid_response": "The LMM OAuth response was invalid; authorization has stopped", + "auth.network": "The LMM network request failed; check connectivity and whether the server has enabled the lmm client", + "auth.not_logged_in": "Not signed in; run lmm login first", + "auth.output": "Could not display the login URL; login has stopped", + "auth.refresh_blocked": "The previous refresh did not complete reliably. The old refresh token will not be reused; run logout, then login", + "auth.timeout": "Login timed out; no further requests were sent", + "auth.unauthorized": "The server rejected authorization; sign in again or check that the lmm client is deployed", + "auth.unstored_grant": "The new grant could not be saved and revocation could not be confirmed; check LMM CLI on the LMM authorization page", + "callback.invalid": "Invalid or denied authorization. Return to LMM CLI.", + "callback.received": "Authorization received. Return to LMM CLI to check completion.", + "cli.about": "LMM: provider presets, software discovery, and read-only OAuth (development preview)", + "cmd.catalog": "Search the software catalog; entries are not verified integrations", + "cmd.disconnect": "Plan removal of an integration and its application grant", + "cmd.doctor": "Run read-only checks; --fix still requires adapter support and approval", + "cmd.help": "Print help for a command", + "cmd.install": "Plan software installation; LMM login is not required", + "cmd.login": "Sign in with a browser and store credentials in the system vault", + "cmd.logout": "Sign out of the CLI without revoking other application grants", + "cmd.models": "Read authorized models and prices without calling or selecting a model", + "cmd.presets": "Search the built-in provider snapshot without reading credentials or using the network", + "cmd.restore": "Plan field-level restoration while preserving later user edits", + "cmd.run": "Request a launch with the selected software configuration", + "cmd.setup": "Preview setup; writes stay blocked until a verified adapter is available", + "cmd.status": "Inspect local environment and path evidence without making changes", + "cmd.sync": "Plan an LMM refresh while preserving the current provider and model", + "cmd.uninstall": "Plan removal of managed software; keep user data by default", + "cmd.update": "Plan updates for software explicitly placed under management", + "error.auth_unattended": "The system vault may need to be unlocked; OAuth commands do not support --non-interactive yet. No credentials or network were accessed", + "error.catalog_output": "Could not write the software catalog", + "error.config_shape": "not a bounded regular configuration file", + "error.config_size": "configuration exceeds size limit", + "error.instance_unsupported": "Instance selection is not supported for this software; --instance will not be ignored", + "error.invalid_instance": "--instance must be an absolute local path for this environment; remote and container adapters are not available", + "error.json_output": "Could not write JSON", + "error.login_interactive": "Login needs browser approval; run lmm login in an interactive terminal. Unattended mode will not wait or consent for you", + "error.login_output": "Could not write the login result", + "error.logout_output": "Could not write the logout result", + "error.menu_output": "Could not display the selection menu", + "error.models_output": "Could not write models", + "error.no_selection": "No target selected; no changes were made", + "error.output": "Could not write results", + "error.presets_output": "Could not write provider presets", + "error.presets_read": "Could not read the built-in provider presets", + "error.selection_input": "Could not read the selection", + "error.target_required": "Unattended mode requires a software ID or --all; no changes were made", + "error.unknown_software": "Unknown software ID; run lmm catalog to find the exact name", + "error.unsupported_language": "Unsupported language. Use en or zh-CN.", + "help.arguments": "Arguments", + "help.commands": "Commands", + "help.help": "Print help", + "help.options": "Options", + "help.template": "{about-with-newline}\nUsage: {usage}\n\n{all-args}{after-help}", + "help.version": "Print version", + "kind.local": "local", + "kind.relay": "relay", + "kind.remote": "remote", + "kind.vendor": "vendor", + "note.all_blocked": "No automatic integration adapter has been verified; --all has no executable targets. No software was installed or changed", + "note.doctor_incomplete": "Diagnosis is incomplete: only local path evidence was checked. Check the selected instance, version, and configuration manager. No network or paid tests were run", + "note.dry_run": "Plan only; blockers remain unresolved. This is not evidence of a completed integration", + "note.execution_blocked": "No safe execution adapter is available for this target; no software was started or removed, no configuration changed, and no grants revoked", + "note.fix_blocked": "No repair adapter has been verified; configuration was not changed", + "note.install": "Installation does not require LMM login; a verified installation adapter is not available yet", + "note.yes": "--yes does not bypass missing capabilities, authorization, or cost limits", + "observation.not_found": "Not found", + "observation.present": "Present", + "observation.unknown": "Unknown", + "output.authorization_url": "Approve LMM CLI in your browser (catalog and balance reads only; no application migration):\n{url}", + "output.blocked_plan": "\n{software} plan blocked: no configuration changes, paid calls, or restarts", + "output.browser_failed": "The browser could not be opened. Open the URL above in a browser on this machine.", + "output.catalog_entry": "{id} - {name}\n {purpose}\n Source: {source}\n Installation/integration: unavailable; maintenance: local evidence only; versions/platforms/scenarios: unverified\n", + "output.evidence": " {path}: {observation} (path evidence only)", + "output.login": "LMM CLI is signed in. Credentials are stored in the system vault. Applications have not been granted model-call permission; run lmm models to read the catalog.", + "output.logout": "CLI signed out. Other application grants were not revoked and cannot be listed by this version.", + "output.model_entry": "{id} / {upstream} / {group}\n Price unit: {currency} / {unit}; input: {input}, output: {output}, per request: {request}; basis: {basis}\n null/None means unknown; no model was called or selected.", + "output.models": "Authorized models (application compatibility is not verified; final costs depend on usage):", + "output.presets": "Built-in provider presets (Magpie snapshot {revision}); configuration and connectivity were not checked. No providers were added.", + "output.scope": "Scope: current user and runtime environment; checked at {time} (Unix seconds)", + "output.target": "\n{software}: installation cannot be confirmed; installation and configuration managers are unknown", + "output.verification": " LMM added/active: unknown; authorization, connectivity, application, streaming, and tool calls: not checked", + "plan.application_grant": "CLI login allows only catalog and balance reads; separate application authorization is not connected yet", + "plan.install_adapter": "A verified software version, download-source check, and installation adapter are required", + "plan.paid_verification": "Paid verification requires explicit consent, a known quote, and a server-enforced spending limit", + "plan.restore_adapter": "No target change journal or native transaction adapter is connected; backups cannot be overwritten directly", + "plan.revoked_grant": "The application grant was revoked; explicit approval is required to authorize it again", + "plan.unknown_selection": "The current provider is unknown; first read the selected instance configuration", + "plan.unverified_target": "The software version, configuration manager, and actual request path have not been verified", + "plan.write_adapter": "This version has no verified configuration-write adapter for the target software", + "purpose.astrbot": "Agent and bot runtime for messaging platforms", + "purpose.cc_switch": "Provider configuration management and local routing", + "purpose.coding": "Coding assistant for the terminal", + "purpose.pi": "Terminal agent; its LMM provider is a separate project", + "restore.conflict": "The field changed after LMM wrote it; restoration has stopped", + "restore.duplicate_field": "The change journal contains duplicate fields", + "restore.invalid_pointer": "Could not locate the parent object for the field to restore", + "restore.unsupported_shape": "Only individual scalar fields in objects are supported; collections need a dedicated adapter", + "select.prompt": "Enter a number, or press Enter to cancel: ", + "select.title": "Select software to plan setup (no integration has been verified yet):" +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json new file mode 100644 index 0000000..8f93f29 --- /dev/null +++ b/locales/zh-CN.json @@ -0,0 +1,122 @@ +{ + "arg.activate": "明确请求添加并切换到 LMM(不表示已执行)", + "arg.add_only": "仅添加 LMM,保留当前选择", + "arg.all": "仅包含当前环境中已安装且受支持的目标;更新还要求管理授权", + "arg.dry_run": "只生成计划,不执行修改", + "arg.fix": "请求修复已获允许的 LMM 配置;不会绕过能力检查", + "arg.instance": "明确的本地实例根目录;不自动扫描远程或 Docker 实例", + "arg.issuer": "信任的 LMM HTTPS origin(自托管时显式指定)", + "arg.json": "输出结构化 JSON;不交互、不输出配置内容", + "arg.lang": "显示语言:en 或 zh-CN;优先于 LMM_LANG 和系统语言变量", + "arg.no_browser": "只显示登录链接;仍需要浏览器能访问当前机器的回环端口", + "arg.non_interactive": "无人值守:缺少目标或授权时直接返回", + "arg.query": "搜索关键词;留空则列出全部条目", + "arg.report": "生成可主动分享的摘要,去除路径和配置内容", + "arg.software": "精确的软件目录 ID,例如 astrbot 或 cc-switch", + "arg.timeout": "浏览器回调等待上限(秒)", + "arg.yes": "确认已展示范围;不代表允许切换账号、服务商或付费测试", + "auth.already_logged_in": "已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权", + "auth.busy": "另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁", + "auth.callback": "无法绑定本机 OAuth 回调端口", + "auth.credential_store": "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件", + "auth.denied": "授权已取消或被拒绝", + "auth.invalid_issuer": "issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据", + "auth.invalid_response": "LMM OAuth 响应不符合约定,已停止授权", + "auth.network": "LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端", + "auth.not_logged_in": "尚未登录;请先运行 lmm login", + "auth.output": "无法输出登录入口;登录已停止", + "auth.refresh_blocked": "上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login", + "auth.timeout": "登录等待超时;没有继续发起请求", + "auth.unauthorized": "服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署", + "auth.unstored_grant": "新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权", + "callback.invalid": "授权无效或已被拒绝。请返回 LMM CLI。", + "callback.received": "已收到授权。请返回 LMM CLI 查看是否完成登录。", + "cli.about": "LMM 安装与接入工具(开发预览:环境检查与操作规划)", + "cmd.catalog": "搜索软件目录;目录条目不代表已验证兼容", + "cmd.disconnect": "解除指定软件接入并撤销对应授权", + "cmd.doctor": "只读诊断;--fix 仍受适配器能力与确认范围限制", + "cmd.help": "显示指定命令的帮助", + "cmd.install": "安装软件;不要求 LMM 登录", + "cmd.login": "通过浏览器登录,凭据保存在系统凭据库", + "cmd.logout": "退出 CLI;不撤销独立应用授权", + "cmd.models": "读取当前授权模型与计费信息,不发起模型调用或更改选择", + "cmd.presets": "搜索内置服务商预设快照;不读取配置、凭据或网络", + "cmd.restore": "按字段恢复 LMM 变更,保留后续修改", + "cmd.run": "按当前配置启动指定软件", + "cmd.setup": "规划完整接入;当前版本在未具备适配器时明确阻止写入", + "cmd.status": "只读检查环境、安装线索与各层验证状态", + "cmd.sync": "同步 LMM 信息,保留当前服务商与模型", + "cmd.uninstall": "卸载已确认纳入管理的软件,默认保留数据", + "cmd.update": "更新明确纳入管理的软件", + "error.auth_unattended": "当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络", + "error.catalog_output": "无法输出目录", + "error.config_shape": "配置不是大小受限的普通文件", + "error.config_size": "配置超出大小限制", + "error.instance_unsupported": "此软件尚未实现实例选择,不能忽略 --instance 后继续操作", + "error.invalid_instance": "--instance 必须是当前环境的绝对本地路径;远程与容器适配器尚未提供", + "error.json_output": "无法输出 JSON", + "error.login_interactive": "登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意", + "error.login_output": "无法输出登录结果", + "error.logout_output": "无法输出退出结果", + "error.menu_output": "无法显示选择菜单", + "error.models_output": "无法输出模型", + "error.no_selection": "未选择目标;没有执行任何修改", + "error.output": "无法输出结果", + "error.presets_output": "无法输出服务商预设", + "error.presets_read": "内置服务商预设无法读取", + "error.selection_input": "无法读取选择", + "error.target_required": "无人值守模式需要明确软件或 --all;没有执行任何修改", + "error.unknown_software": "未知软件 ID;运行 lmm catalog 查看精确名称", + "error.unsupported_language": "不支持此语言。请使用 en 或 zh-CN。", + "help.arguments": "参数", + "help.commands": "命令", + "help.help": "显示帮助", + "help.options": "选项", + "help.template": "{about-with-newline}\n用法:{usage}\n\n{all-args}{after-help}", + "help.version": "显示版本", + "kind.local": "本地", + "kind.relay": "中转", + "kind.remote": "远程", + "kind.vendor": "服务商", + "note.all_blocked": "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", + "note.doctor_incomplete": "诊断未完成:仅检查本地路径线索;请核对目标实例、版本及配置管理关系。没有执行网络或付费测试", + "note.dry_run": "仅规划;阻塞项仍需解决,不能作为接入完成的证据", + "note.execution_blocked": "未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权", + "note.fix_blocked": "当前没有经过验证的修复适配器;未修改配置", + "note.install": "安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器", + "note.yes": "--yes 不会跳过缺失能力、授权或费用限制", + "observation.not_found": "未找到", + "observation.present": "存在", + "observation.unknown": "未知", + "output.authorization_url": "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}", + "output.blocked_plan": "\n{software} 计划已阻止:无配置修改、无付费调用、无重启", + "output.browser_failed": "浏览器未能自动打开,请在本机浏览器打开上述链接。", + "output.catalog_entry": "{id} — {name}\n {purpose}\n 来源:{source}\n 安装/接入:尚未支持;维护:仅本地线索检查;兼容版本/平台/场景:未验证\n", + "output.evidence": " {path}:{observation}(仅路径线索)", + "output.login": "LMM CLI 已登录,凭据已保存到系统凭据库。尚未授予应用调用权限;可运行 lmm models。", + "output.logout": "CLI 已退出;其他应用授权未撤销,当前版本尚不能列出这些授权。", + "output.model_entry": "{id} / {upstream} / {group}\n 价格单位:{currency} / {unit};输入:{input},输出:{output},每次请求:{request};依据:{basis}\n null/None 表示未知;未调用或切换模型。", + "output.models": "当前授权模型(不代表目标软件兼容;价格可能随使用变化):", + "output.presets": "内置服务商预设(Magpie 快照 {revision});配置与连通性未检查,未添加服务商。", + "output.scope": "范围:当前用户、当前运行环境;检查时间:{time}(Unix 秒)", + "output.target": "\n{software}:安装状态无法确认;安装管理者、配置管理者均未知", + "output.verification": " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查", + "plan.application_grant": "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", + "plan.install_adapter": "缺少经过验证的软件版本、下载来源校验及安装适配器", + "plan.paid_verification": "付费验证需要明确同意、已知报价和服务端可执行的消费上限", + "plan.restore_adapter": "尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份", + "plan.revoked_grant": "应用授权已撤销;需要明确确认重新授权", + "plan.unknown_selection": "无法确认当前服务商;需要先读取选定实例的实际配置", + "plan.unverified_target": "未验证软件版本、配置管理关系及实际调用路径", + "plan.write_adapter": "此版本尚无经过验证的目标软件写入适配器", + "purpose.astrbot": "消息平台 Agent 与机器人运行环境", + "purpose.cc_switch": "服务商配置管理与本地路由", + "purpose.coding": "终端编程助手", + "purpose.pi": "终端 Agent,LMM Provider 已有独立项目", + "restore.conflict": "字段在 LMM 修改后再次变化,恢复已暂停", + "restore.duplicate_field": "变更记录包含重复字段", + "restore.invalid_pointer": "无法定位恢复字段的父对象", + "restore.unsupported_shape": "仅支持对象中的单个标量字段;集合需要专用适配器", + "select.prompt": "输入序号,或直接回车取消:", + "select.title": "选择要规划接入的软件(当前均未完成集成验证):" +} diff --git a/scripts/check-i18n.py b/scripts/check-i18n.py new file mode 100644 index 0000000..6d0cf5b --- /dev/null +++ b/scripts/check-i18n.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Check embedded translations with only the Python standard library.""" +from __future__ import annotations + +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +HAN = re.compile("[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\U00020000-\U0003134f]") +PLACEHOLDER = re.compile(r"\{([a-zA-Z][a-zA-Z0-9_-]*)\}") +CALL_KEY = re.compile(r'(?\s*"([a-z]+\.[a-z_]+)"') + + +def unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate translation key: {key}") + result[key] = value + return result + + +def placeholders(value: str) -> list[str]: + remainder = PLACEHOLDER.sub("", value) + if "{" in remainder or "}" in remainder: + raise ValueError(f"invalid placeholder syntax: {value!r}") + return sorted(PLACEHOLDER.findall(value)) + + +def main() -> None: + catalogs = { + path.stem: json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object) + for path in sorted((ROOT / "locales").glob("*.json")) + } + reference = catalogs["en"] + for locale, catalog in catalogs.items(): + if set(catalog) != set(reference): + raise ValueError(f"{locale}: missing={set(reference) - set(catalog)}, extra={set(catalog) - set(reference)}") + for key, value in catalog.items(): + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{locale}.{key}: expected a nonempty string") + if placeholders(value) != placeholders(reference[key]): + raise ValueError(f"{locale}.{key}: placeholders differ from English") + used: set[str] = set() + for path in (ROOT / "src").rglob("*.rs"): + source = path.read_text(encoding="utf-8") + if HAN.search(source): + raise ValueError(f"{path.relative_to(ROOT)}: move Chinese UI text into locales/") + used.update(CALL_KEY.findall(source)) + used.update(MATCH_KEY.findall(source)) + unknown = used - set(reference) + unused = set(reference) - used + if unknown or unused: + raise ValueError(f"unknown message keys={unknown}; unused message keys={unused}") + print(f"i18n: {len(catalogs)} catalogs, {len(reference)} keys; placeholders and runtime references verified") + + +if __name__ == "__main__": + main() diff --git a/src/catalog.rs b/src/catalog.rs index 3b5c12c..f845fb9 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -1,5 +1,6 @@ //! Explicit project identities and independently stated integration capabilities. +use crate::i18n::{Locale, Message, msg}; use serde::Serialize; /// A capability with no verified adapter must never be promoted by discovery. @@ -22,7 +23,7 @@ pub struct Software { /// Canonical project identity, not a download mirror. pub source: &'static str, /// Intended use. - pub purpose: &'static str, + pub purpose: Message, /// Installation capability of this LMM release. pub installation: Capability, /// Integration capability of this LMM release. @@ -39,7 +40,7 @@ const fn software( id: &'static str, name: &'static str, source: &'static str, - purpose: &'static str, + purpose: Message, executables: &'static [&'static str], ) -> Software { Software { @@ -61,42 +62,42 @@ pub static SOFTWARE: &[Software] = &[ "astrbot", "AstrBot", "https://github.com/AstrBotDevs/AstrBot", - "消息平台 Agent 与机器人运行环境", + msg("purpose.astrbot"), &["astrbot"], ), software( "cc-switch", "CC Switch", "https://github.com/farion1231/cc-switch", - "服务商配置管理与本地路由", + msg("purpose.cc_switch"), &["cc-switch"], ), software( "claude-code", "Claude Code CLI", "https://github.com/anthropics/claude-code", - "终端编程助手", + msg("purpose.coding"), &["claude"], ), software( "codex", "OpenAI Codex CLI", "https://github.com/openai/codex", - "终端编程助手", + msg("purpose.coding"), &["codex"], ), software( "gemini-cli", "Gemini CLI", "https://github.com/google-gemini/gemini-cli", - "终端编程助手", + msg("purpose.coding"), &["gemini"], ), software( "pi", "Pi coding agent", "https://github.com/badlogic/pi-mono", - "终端 Agent,LMM Provider 已有独立项目", + msg("purpose.pi"), &["pi"], ), ]; @@ -108,13 +109,16 @@ pub fn find(id: &str) -> Option<&'static Software> { /// Search project identity and purpose without network access. pub fn search(query: &str) -> Vec<&'static Software> { - let query = query.to_lowercase(); + let query = query.trim().to_lowercase(); SOFTWARE .iter() .filter(|item| { - [item.id, item.name, item.purpose] + [item.id, item.name] .iter() .any(|text| text.to_lowercase().contains(&query)) + || Locale::ALL.iter().any(|locale| { + locale.text(item.purpose.key()).to_lowercase().contains(&query) + }) }) .collect() } diff --git a/src/discovery.rs b/src/discovery.rs index 2e1bf22..444cf32 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,6 +1,6 @@ //! Bounded local probes. Finding a path is evidence, not proof of installation. -use crate::catalog::Software; +use crate::{catalog::Software, i18n::{Message, msg}}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::{ @@ -24,6 +24,17 @@ pub enum Observation { Unknown, } +impl Observation { + /// Localized display label; serialized observation values remain unchanged. + pub const fn label(self) -> Message { + msg(match self { + Self::Present => "observation.present", + Self::NotFound => "observation.not_found", + Self::Unknown => "observation.unknown", + }) + } +} + /// Public evidence contains no configuration values or credentials. #[derive(Debug, Serialize)] pub struct Evidence { @@ -189,7 +200,7 @@ pub fn fingerprint(path: &Path) -> io::Result { if !meta.is_file() || meta.len() > MAX_CONFIG_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, - "not a bounded regular configuration file", + msg("error.config_shape"), )); } let mut data = Vec::new(); @@ -199,7 +210,7 @@ pub fn fingerprint(path: &Path) -> io::Result { if data.len() as u64 > MAX_CONFIG_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, - "configuration exceeds size limit", + msg("error.config_size"), )); } Ok(format!("{:x}", Sha256::digest(data))) diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..eeffc4b --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,207 @@ +//! Embedded CLI translations. Machine-readable messages always use English. + +use serde::{Serialize, Serializer}; +use std::{collections::BTreeMap, ffi::OsString, fmt, str::FromStr, sync::OnceLock}; + +type Catalog = BTreeMap; +static ENGLISH: OnceLock = OnceLock::new(); +static CHINESE: OnceLock = OnceLock::new(); +static LANGUAGE: OnceLock = OnceLock::new(); + +/// Languages shipped with the binary; no files or network are needed at runtime. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Locale { + #[default] + En, + ZhCn, +} + +impl Locale { + pub const ALL: [Self; 2] = [Self::En, Self::ZhCn]; + + /// Recognize supported language tags and common POSIX locale names. + pub fn from_name(value: &str) -> Option { + let name = value + .trim() + .split(['.', '@']) + .next() + .unwrap_or_default() + .replace('_', "-") + .to_ascii_lowercase(); + match name.as_str() { + "c" | "posix" | "en" => Some(Self::En), + "zh" | "zh-cn" | "zh-sg" | "zh-hans" => Some(Self::ZhCn), + name if name.starts_with("en-") => Some(Self::En), + name if name.starts_with("zh-hans-") => Some(Self::ZhCn), + _ => None, + } + } + + /// Resolve language before clap renders help. Clap still validates all arguments. + /// Stop at `--` so positional data cannot change the language. + pub fn detect(args: &[OsString], get_env: impl Fn(&str) -> Option) -> Self { + let mut selected = None; + let mut args = args.iter().skip(1); + while let Some(arg) = args.next() { + if arg == "--" { + break; + } + let value = if arg == "--lang" { + args.next().and_then(|value| value.to_str()) + } else { + arg.to_str().and_then(|arg| arg.strip_prefix("--lang=")) + }; + if let Some(value) = value { + selected = Self::from_name(value); + } + } + if let Some(locale) = selected { + return locale; + } + for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { + if let Some(value) = get_env(key).filter(|value| !value.is_empty()) { + return value + .to_str() + .and_then(Self::from_name) + .unwrap_or_default(); + } + } + Self::default() + } + + fn catalog(self) -> &'static Catalog { + let (cache, source) = match self { + Self::En => (&ENGLISH, include_str!("../locales/en.json")), + Self::ZhCn => (&CHINESE, include_str!("../locales/zh-CN.json")), + }; + cache.get_or_init(|| { + serde_json::from_str(source) + .unwrap_or_else(|error| panic!("invalid embedded translation catalog: {error}")) + }) + } + + /// Missing translations fall back to English, then to the visible message key. + pub fn text(self, key: &'static str) -> &'static str { + self.catalog() + .get(key) + .or_else(|| Self::En.catalog().get(key)) + .map_or(key, String::as_str) + } +} + +impl FromStr for Locale { + type Err = Message; + + fn from_str(value: &str) -> Result { + Self::from_name(value).ok_or(msg("error.unsupported_language")) + } +} + +/// Select once at process startup. Tests should use explicit locales instead. +pub fn initialize(locale: Locale) -> Result<(), Locale> { + LANGUAGE.set(locale) +} + +pub fn locale() -> Locale { + LANGUAGE.get().copied().unwrap_or_default() +} + +/// A stable message key, separated from both presentation and serialization. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Message(&'static str); + +pub const fn msg(key: &'static str) -> Message { + Message(key) +} + +pub fn text(key: &'static str) -> &'static str { + msg(key).text() +} + +impl Message { + pub const fn key(self) -> &'static str { + self.0 + } + + pub fn text(self) -> &'static str { + locale().text(self.0) + } + + /// Substitute named values once; inserted text is never parsed as a template. + pub fn render(self, values: &[(&str, &str)]) -> String { + self.render_in(locale(), values) + } + + pub fn render_in(self, locale: Locale, values: &[(&str, &str)]) -> String { + let mut rest = locale.text(self.0); + let mut output = String::with_capacity(rest.len()); + while let Some(start) = rest.find('{') { + output.push_str(&rest[..start]); + let Some(end) = rest[start..].find('}').map(|end| start + end) else { + output.push_str(&rest[start..]); + return output; + }; + let name = &rest[start + 1..end]; + match values.iter().find(|(key, _)| *key == name) { + Some((_, value)) => output.push_str(value), + None => output.push_str(&rest[start..=end]), + } + rest = &rest[end + 1..]; + } + output.push_str(rest); + output + } +} + +impl fmt::Display for Message { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.text()) + } +} + +impl std::error::Error for Message {} + +impl Serialize for Message { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(Locale::En.text(self.0)) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + #[test] + fn shipped_catalogs_have_the_same_nonempty_keys() { + let reference = Locale::En.catalog(); + assert!(!reference.is_empty()); + for locale in Locale::ALL { + let catalog = locale.catalog(); + assert_eq!(reference.keys().collect::>(), catalog.keys().collect::>()); + assert!(catalog.values().all(|value| !value.trim().is_empty())); + } + } + + #[test] + fn unknown_keys_remain_visible() { + assert_eq!(Locale::ZhCn.text("missing.test.key"), "missing.test.key"); + } + + #[test] + fn replacement_values_cannot_inject_other_placeholders() { + let output = msg("output.catalog_entry").render_in( + Locale::En, + &[("id", "{source}"), ("name", "name"), ("purpose", "purpose"), ("source", "URL")], + ); + assert!(output.starts_with("{source} - name")); + assert!(output.contains("Source: URL")); + } + + #[test] + fn serialization_does_not_depend_on_display_language() { + let message = msg("error.unknown_software"); + assert_ne!(Locale::En.text(message.key()), Locale::ZhCn.text(message.key())); + assert_eq!(serde_json::to_value(message).unwrap(), Locale::En.text(message.key())); + } +} diff --git a/src/lib.rs b/src/lib.rs index a0fed60..070df8b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod catalog; pub mod discovery; pub mod environment; +pub mod i18n; pub mod oauth; pub mod plan; pub mod provider; diff --git a/src/main.rs b/src/main.rs index cd927a2..2728bd4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ -use clap::{Args, Parser, Subcommand}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand}; use lmm::{ catalog, discovery::{self, ProbeContext, TargetStatus}, environment::{self, Environment}, + i18n::{self, Locale, Message, msg, text}, oauth, plan::{Intent, Preview}, provider, @@ -20,13 +21,15 @@ use std::{ #[command( name = "lmm", version, - about = "LMM 安装与接入工具(开发预览:环境检查与操作规划)" + about = text("cli.about") )] struct Cli { - /// 输出结构化 JSON;不交互、不输出配置内容 + #[arg(long, global = true, value_name = "LANG", help = text("arg.lang"))] + lang: Option, + #[arg(help = text("arg.json"))] #[arg(long, global = true)] json: bool, - /// 无人值守:缺少目标或授权时直接返回 + #[arg(help = text("arg.non_interactive"))] #[arg(long, global = true)] non_interactive: bool, #[command(subcommand)] @@ -35,41 +38,47 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// 搜索内置服务商预设快照;不读取配置、凭据或网络 - Presets { query: Option }, - /// 搜索软件目录;目录条目不代表已验证兼容 - Catalog { query: Option }, - /// 规划完整接入;当前版本在未具备适配器时明确阻止写入 + #[command(about = text("cmd.presets"))] + Presets { + #[arg(help = text("arg.query"))] + query: Option, + }, + #[command(about = text("cmd.catalog"))] + Catalog { + #[arg(help = text("arg.query"))] + query: Option, + }, + #[command(about = text("cmd.setup"))] Setup(SetupArgs), - /// 安装软件;不要求 LMM 登录 + #[command(about = text("cmd.install"))] Install(OperationArgs), - /// 通过浏览器登录,凭据保存在系统凭据库 + #[command(about = text("cmd.login"))] Login(LoginArgs), - /// 退出 CLI;不撤销独立应用授权 + #[command(about = text("cmd.logout"))] Logout(AuthArgs), - /// 读取当前授权模型与计费信息,不发起模型调用或更改选择 + #[command(about = text("cmd.models"))] Models(AuthArgs), - /// 只读检查环境、安装线索与各层验证状态 + #[command(about = text("cmd.status"))] Status(TargetArgs), - /// 同步 LMM 信息,保留当前服务商与模型 + #[command(about = text("cmd.sync"))] Sync(OperationArgs), - /// 更新明确纳入管理的软件 + #[command(about = text("cmd.update"))] Update(OperationArgs), - /// 只读诊断;--fix 仍受适配器能力与确认范围限制 + #[command(about = text("cmd.doctor"))] Doctor(DoctorArgs), - /// 按字段恢复 LMM 变更,保留后续修改 + #[command(about = text("cmd.restore"))] Restore(OperationArgs), - /// 解除指定软件接入并撤销对应授权 + #[command(about = text("cmd.disconnect"))] Disconnect(RequiredTarget), - /// 卸载已确认纳入管理的软件,默认保留数据 + #[command(about = text("cmd.uninstall"))] Uninstall(RequiredTarget), - /// 按当前配置启动指定软件 + #[command(about = text("cmd.run"))] Run(RequiredTarget), } #[derive(Args)] struct AuthArgs { - /// 信任的 LMM HTTPS origin(自托管时显式指定) + #[arg(help = text("arg.issuer"))] #[arg(long, default_value = oauth::DEFAULT_ISSUER)] issuer: String, } @@ -78,19 +87,19 @@ struct AuthArgs { struct LoginArgs { #[command(flatten)] auth: AuthArgs, - /// 只显示登录链接;仍需要浏览器能访问当前机器的回环端口 + #[arg(help = text("arg.no_browser"))] #[arg(long)] no_browser: bool, - /// 浏览器回调等待上限(秒) + #[arg(help = text("arg.timeout"))] #[arg(long, default_value_t = 180, value_parser = clap::value_parser!(u64).range(10..=600))] timeout: u64, } #[derive(Args)] struct TargetArgs { - /// 精确的软件目录 ID,例如 astrbot 或 cc-switch + #[arg(help = text("arg.software"))] software: Option, - /// 明确的本地实例根目录;不自动扫描远程或 Docker 实例 + #[arg(help = text("arg.instance"))] #[arg(long, requires = "software")] instance: Option, } @@ -99,13 +108,13 @@ struct TargetArgs { struct OperationArgs { #[command(flatten)] target: TargetArgs, - /// 仅包含当前环境中已安装且受支持的目标;更新还要求管理授权 + #[arg(help = text("arg.all"))] #[arg(long, conflicts_with_all = ["software", "instance"])] all: bool, - /// 只生成计划,不执行修改 + #[arg(help = text("arg.dry_run"))] #[arg(long)] dry_run: bool, - /// 确认已展示范围;不代表允许切换账号、服务商或付费测试 + #[arg(help = text("arg.yes"))] #[arg(long)] yes: bool, } @@ -114,10 +123,10 @@ struct OperationArgs { struct SetupArgs { #[command(flatten)] operation: OperationArgs, - /// 仅添加 LMM,保留当前选择 + #[arg(help = text("arg.add_only"))] #[arg(long, conflicts_with = "activate")] add_only: bool, - /// 明确请求添加并切换到 LMM(不表示已执行) + #[arg(help = text("arg.activate"))] #[arg(long)] activate: bool, } @@ -126,18 +135,18 @@ struct SetupArgs { struct DoctorArgs { #[command(flatten)] target: TargetArgs, - /// 请求修复已获允许的 LMM 配置;不会绕过能力检查 + #[arg(help = text("arg.fix"))] #[arg(long)] fix: bool, - /// 生成可主动分享的摘要,去除路径和配置内容 - #[arg(long)] + #[arg(help = text("arg.report"))] report: bool, } #[derive(Args)] struct RequiredTarget { + #[arg(help = text("arg.software"))] software: String, - #[arg(long)] + #[arg(long, help = text("arg.instance"))] instance: Option, } @@ -151,7 +160,7 @@ struct Report { environment: Environment, targets: Vec, plans: Vec, - notes: Vec<&'static str>, + notes: Vec, } impl Report { @@ -193,20 +202,20 @@ fn context() -> ProbeContext { } } -fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result, String> { +fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result, Message> { if target .instance .as_ref() .is_some_and(|path| !path.is_absolute()) { - return Err("--instance 必须是当前环境的绝对本地路径;远程与容器适配器尚未提供".into()); + return Err(msg("error.invalid_instance")); } match target.software.as_deref() { Some(id) => { let software = catalog::find(id) - .ok_or_else(|| "未知软件 ID;运行 lmm catalog 查看精确名称".to_owned())?; + .ok_or(msg("error.unknown_software"))?; if target.instance.is_some() && !matches!(id, "astrbot" | "cc-switch") { - return Err("此软件尚未实现实例选择,不能忽略 --instance 后继续操作".into()); + return Err(msg("error.instance_unsupported")); } Ok(vec![discovery::discover( software, @@ -221,20 +230,20 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result Result { +fn select_software() -> Result { let mut output = io::stderr().lock(); - writeln!(output, "选择要规划接入的软件(当前均未完成集成验证):") - .map_err(|_| "无法显示选择菜单")?; + writeln!(output, "{}", msg("select.title")) + .map_err(|_| msg("error.menu_output"))?; for (i, software) in catalog::SOFTWARE.iter().enumerate() { writeln!(output, " {}. {} ({})", i + 1, software.name, software.id) - .map_err(|_| "无法显示选择菜单")?; + .map_err(|_| msg("error.menu_output"))?; } - write!(output, "输入序号,或直接回车取消:").map_err(|_| "无法显示选择菜单")?; - output.flush().map_err(|_| "无法显示选择菜单")?; + write!(output, "{}", msg("select.prompt")).map_err(|_| msg("error.menu_output"))?; + output.flush().map_err(|_| msg("error.menu_output"))?; let mut input = String::new(); io::stdin() .read_line(&mut input) - .map_err(|_| "无法读取选择")?; + .map_err(|_| msg("error.selection_input"))?; let index = input .trim() .parse::() @@ -243,7 +252,7 @@ fn select_software() -> Result { index .and_then(|index| catalog::SOFTWARE.get(index)) .map(|software| software.id.to_owned()) - .ok_or_else(|| "未选择目标;没有执行任何修改".to_owned()) + .ok_or(msg("error.no_selection")) } fn operation_report( @@ -252,11 +261,11 @@ fn operation_report( intent: Intent, interactive: bool, context: &ProbeContext, -) -> Result { +) -> Result { let selected; let target = if args.target.software.is_none() && !args.all { if !interactive { - return Err("无人值守模式需要明确软件或 --all;没有执行任何修改".into()); + return Err(msg("error.target_required")); } selected = TargetArgs { software: Some(select_software()?), @@ -278,104 +287,87 @@ fn operation_report( let mut preview = Preview::blocked(target.software, intent); if name == "install" { preview.account = "not_required"; - preview.blockers = vec!["缺少经过验证的软件版本、下载来源校验及安装适配器"]; + preview.blockers = vec![msg("plan.install_adapter")]; } else if name == "restore" { preview.account = "not_required"; - preview.blockers = vec!["尚未接入目标的变更日志与原生事务适配器,不能直接覆盖备份"]; + preview.blockers = vec![msg("plan.restore_adapter")]; } report.plans.push(preview); } if args.all { report.notes.push( - "当前版本没有经过验证的自动接入适配器,--all 没有可执行目标;未安装或修改任何软件", + msg("note.all_blocked"), ); } if args.dry_run { report .notes - .push("仅规划;阻塞项仍需解决,不能作为接入完成的证据"); + .push(msg("note.dry_run")); } if args.yes { - report.notes.push("--yes 不会跳过缺失能力、授权或费用限制"); + report.notes.push(msg("note.yes")); } if name == "install" { report .notes - .push("安装本身不需要 LMM 登录;当前缺少经过验证的安装适配器"); + .push(msg("note.install")); } Ok(report) } -fn write_json(value: &impl Serialize) -> Result<(), String> { +fn write_json(value: &impl Serialize) -> Result<(), Message> { let mut output = io::stdout().lock(); - serde_json::to_writer_pretty(&mut output, value).map_err(|_| "无法输出 JSON".to_owned())?; - writeln!(output).map_err(|_| "无法输出 JSON".to_owned()) + serde_json::to_writer_pretty(&mut output, value).map_err(|_| msg("error.json_output"))?; + writeln!(output).map_err(|_| msg("error.json_output")) } -fn emit(report: &Report, json: bool) -> Result<(), String> { +fn emit(report: &Report, json: bool) -> Result<(), Message> { if json { return write_json(report); } let mut output = io::stdout().lock(); writeln!( output, - "LMM · {} · {} / {}", + "LMM - {} - {} / {}", report.command, report.environment.os, report.environment.arch ) - .map_err(|_| "无法输出结果")?; - writeln!( - output, - "范围:当前用户、当前运行环境;检查时间:{}(Unix 秒)", - report.checked_at - ) - .map_err(|_| "无法输出结果")?; + .map_err(|_| msg("error.output"))?; + writeln!(output, "{}", msg("output.scope").render(&[("time", &report.checked_at.to_string())])) + .map_err(|_| msg("error.output"))?; for target in &report.targets { - writeln!( - output, - "\n{}:安装状态无法确认;安装管理者、配置管理者均未知", - target.software - ) - .map_err(|_| "无法输出结果")?; + writeln!(output, "{}", msg("output.target").render(&[("software", target.software)])) + .map_err(|_| msg("error.output"))?; for evidence in &target.evidence { - // Debug formatting escapes control characters in user-controlled paths. - writeln!( - output, - " {:?}: {:?}(仅路径线索)", - evidence.path, evidence.observation - ) - .map_err(|_| "无法输出结果")?; + // Debug formatting still escapes control characters in user-controlled paths. + let path = format!("{:?}", evidence.path); + let observation = evidence.observation.label(); + writeln!(output, "{}", msg("output.evidence").render(&[("path", &path), ("observation", observation.text())])) + .map_err(|_| msg("error.output"))?; } - writeln!( - output, - " LMM 添加/启用:未知;授权、连通性、目标调用、流式及工具调用:未检查" - ) - .map_err(|_| "无法输出结果")?; + writeln!(output, "{}", msg("output.verification")) + .map_err(|_| msg("error.output"))?; } for plan in &report.plans { - writeln!( - output, - "\n{} 计划已阻止:无配置修改、无付费调用、无重启", - plan.software - ) - .map_err(|_| "无法输出结果")?; + writeln!(output, "{}", msg("output.blocked_plan").render(&[("software", plan.software)])) + .map_err(|_| msg("error.output"))?; for blocker in &plan.blockers { - writeln!(output, " - {blocker}").map_err(|_| "无法输出结果")?; + writeln!(output, " - {blocker}").map_err(|_| msg("error.output"))?; } } for note in &report.notes { - writeln!(output, "\n{note}").map_err(|_| "无法输出结果")?; + writeln!(output, "\n{note}").map_err(|_| msg("error.output"))?; } Ok(()) } -fn execute(cli: &Cli) -> Result { +fn execute(cli: &Cli) -> Result { if cli.non_interactive && matches!( &cli.command, Command::Login(_) | Command::Logout(_) | Command::Models(_) ) { - return Err("当前系统凭据库可能需要解锁交互;OAuth 命令尚不支持 --non-interactive,未访问凭据或网络".into()); + return Err(msg("error.auth_unattended")); } let context = context(); let interactive = !cli.json @@ -385,26 +377,26 @@ fn execute(cli: &Cli) -> Result { let report = match &cli.command { Command::Presets { query } => { let snapshot = provider::search(query.as_deref().unwrap_or_default()) - .map_err(|_| "内置服务商预设无法读取".to_owned())?; + .map_err(|_| msg("error.presets_read"))?; if cli.json { write_json(&snapshot)?; } else { let mut output = io::stdout().lock(); writeln!( output, - "内置服务商预设(Magpie 快照 {});配置与连通性未检查,未添加服务商。", - provider::UPSTREAM_REVISION + "{}", + msg("output.presets").render(&[("revision", provider::UPSTREAM_REVISION)]) ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| msg("error.presets_output"))?; for preset in &snapshot.presets { writeln!( output, "{} — {} ({})", preset.id, preset.name, - preset.kind.as_str() + preset.kind.label() ) - .map_err(|_| "无法输出服务商预设")?; + .map_err(|_| msg("error.presets_output"))?; } } return Ok(0); @@ -416,7 +408,10 @@ fn execute(cli: &Cli) -> Result { } else { let mut output = io::stdout().lock(); for software in entries { - writeln!(output, "{} — {}\n {}\n 来源:{}\n 安装/接入:尚未支持;维护:仅本地线索检查;兼容版本/平台/场景:未验证\n", software.id, software.name, software.purpose, software.source).map_err(|_| "无法输出目录")?; + writeln!(output, "{}", msg("output.catalog_entry").render(&[ + ("id", software.id), ("name", software.name), + ("purpose", software.purpose.text()), ("source", software.source), + ])).map_err(|_| msg("error.catalog_output"))?; } } return Ok(0); @@ -425,12 +420,12 @@ fn execute(cli: &Cli) -> Result { Command::Doctor(args) => { let mut report = Report::new("doctor", inspect(&args.target, &context)?); report.outcome = "incomplete"; - report.notes.push("诊断未完成:仅检查本地路径线索;请核对目标实例、版本及配置管理关系。没有执行网络或付费测试"); + report.notes.push(msg("note.doctor_incomplete")); if args.fix { report.outcome = "blocked"; report .notes - .push("当前没有经过验证的修复适配器;未修改配置"); + .push(msg("note.fix_blocked")); } if args.report { report.redact(); @@ -464,7 +459,7 @@ fn execute(cli: &Cli) -> Result { } Command::Login(args) => { if !interactive { - return Err("登录需要浏览器授权;请在交互终端运行 lmm login。无人值守模式不会等待或代替用户同意".into()); + return Err(msg("error.login_interactive")); } let result = oauth::login( &args.auth.issuer, @@ -473,11 +468,11 @@ fn execute(cli: &Cli) -> Result { let mut output = io::stderr().lock(); writeln!( output, - "请在浏览器确认 LMM CLI 授权(仅目录和余额读取,不迁移任何应用):\n{url}" + "{}", msg("output.authorization_url").render(&[("url", url)]) ) .map_err(|_| oauth::AuthError::Output)?; if !args.no_browser && open::that_detached(url).is_err() { - writeln!(output, "浏览器未能自动打开,请在本机浏览器打开上述链接。") + writeln!(output, "{}", msg("output.browser_failed")) .map_err(|_| oauth::AuthError::Output)?; } Ok(()) @@ -488,8 +483,8 @@ fn execute(cli: &Cli) -> Result { if cli.json { write_json(&status)?; } else { - writeln!(io::stdout().lock(), "LMM CLI 已登录,凭据已保存到系统凭据库。尚未授予应用调用权限;可运行 lmm models。") - .map_err(|_| "无法输出登录结果")?; + writeln!(io::stdout().lock(), "{}", msg("output.login")) + .map_err(|_| msg("error.login_output"))?; } Ok(0) } @@ -506,9 +501,9 @@ fn execute(cli: &Cli) -> Result { } else { writeln!( io::stdout().lock(), - "CLI 已退出;其他应用授权未撤销,当前版本尚不能列出这些授权。" + "{}", msg("output.logout") ) - .map_err(|_| "无法输出退出结果")?; + .map_err(|_| msg("error.logout_output"))?; } Ok(0) } @@ -524,11 +519,21 @@ fn execute(cli: &Cli) -> Result { let mut output = io::stdout().lock(); writeln!( output, - "当前授权模型(不代表目标软件兼容;价格可能随使用变化):" + "{}", msg("output.models") ) - .map_err(|_| "无法输出模型")?; + .map_err(|_| msg("error.models_output"))?; for model in &catalog.models { - writeln!(output, "{:?} · {:?} · {:?}\n 价格单位:{:?} / {:?};输入:{:?},输出:{:?},每次请求:{:?};依据:{:?}\n null/None 表示未知;未调用或切换模型。", model.id, model.upstream_model, model.group, model.pricing.currency, model.pricing.unit, model.pricing.input, model.pricing.output, model.pricing.request, model.pricing.price_basis).map_err(|_| "无法输出模型")?; + writeln!(output, "{}", msg("output.model_entry").render(&[ + ("id", &format!("{:?}", model.id)), + ("upstream", &format!("{:?}", model.upstream_model)), + ("group", &format!("{:?}", model.group)), + ("currency", &format!("{:?}", model.pricing.currency)), + ("unit", &format!("{:?}", model.pricing.unit)), + ("input", &format!("{:?}", model.pricing.input)), + ("output", &format!("{:?}", model.pricing.output)), + ("request", &format!("{:?}", model.pricing.request)), + ("basis", &format!("{:?}", model.pricing.price_basis)), + ])).map_err(|_| msg("error.models_output"))?; } } Ok(0) @@ -550,7 +555,7 @@ fn execute(cli: &Cli) -> Result { report.outcome = "blocked"; report .notes - .push("未实现该目标的安全执行适配器;未启动或卸载软件,未修改配置或撤销授权"); + .push(msg("note.execution_blocked")); report } }; @@ -562,10 +567,10 @@ fn execute(cli: &Cli) -> Result { }) } -fn auth_failure(error: oauth::AuthError, json: bool) -> Result { +fn auth_failure(error: oauth::AuthError, json: bool) -> Result { if json { write_json( - &serde_json::json!({"schema_version":1,"outcome":"blocked","error":error.to_string()}), + &serde_json::json!({"schema_version":1,"outcome":"blocked","error":error.message(),"error_code":error.message().key()}), )?; } else { eprintln!("lmm: {error}"); @@ -573,14 +578,43 @@ fn auth_failure(error: oauth::AuthError, json: bool) -> Result { Ok(3) } +fn localized_help(mut command: clap::Command) -> clap::Command { + command.build(); + if command.get_name() == "help" { + command = command.about(text("cmd.help")); + } + command + .help_template(text("help.template")) + .subcommand_help_heading(text("help.commands")) + .mut_args(|arg| { + let heading = if arg.is_positional() { + text("help.arguments") + } else { + text("help.options") + }; + let arg = match arg.get_id().as_str() { + "help" => arg.help(text("help.help")), + "version" => arg.help(text("help.version")), + _ => arg, + }; + arg.help_heading(heading) + }) + .mut_subcommands(localized_help) +} + fn main() -> ExitCode { - let cli = Cli::parse(); + let args: Vec<_> = env::args_os().collect(); + let selected = Locale::detect(&args, |key| env::var_os(key)); + let _ = i18n::initialize(selected); + let matches = localized_help(Cli::command()).get_matches_from(args); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); + debug_assert!(cli.lang.is_none_or(|locale| locale == selected)); match execute(&cli) { Ok(code) => ExitCode::from(code), Err(error) => { if cli.json { let _ = write_json( - &serde_json::json!({"schema_version": 1, "outcome": "invalid_request", "error": error}), + &serde_json::json!({"schema_version": 1, "outcome": "invalid_request", "error": error, "error_code": error.key()}), ); } else { eprintln!("lmm: {error}"); diff --git a/src/oauth.rs b/src/oauth.rs index 7e2dac9..8010276 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -15,7 +15,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use storage::{SecretStore, StoredSession, SystemStore}; -use thiserror::Error; +use crate::i18n::{Message, msg}; use zeroize::Zeroizing; /// Default trusted deployment issuer; never taken from HTTP redirects. @@ -24,40 +24,55 @@ const CLIENT_ID: &str = "lmm"; const SCOPE: &str = "catalog:read balance:read"; /// Errors contain fixed messages only: never HTTP bodies, URLs or credentials. -#[derive(Debug, Error, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub enum AuthError { - #[error("issuer 必须是规范的 HTTPS 域名 origin,不含端口、路径、查询或凭据")] InvalidIssuer, - #[error("LMM 网络请求失败;请检查网络和服务端是否已启用 lmm 客户端")] Network, - #[error("LMM OAuth 响应不符合约定,已停止授权")] InvalidResponse, - #[error("授权已取消或被拒绝")] Denied, - #[error("登录等待超时;没有继续发起请求")] Timeout, - #[error("无法绑定本机 OAuth 回调端口")] Callback, - #[error( - "无法访问系统凭据库;请解锁 Keychain、Credential Manager 或 Secret Service,不会回退到明文文件" - )] CredentialStore, - #[error("尚未登录;请先运行 lmm login")] NotLoggedIn, - #[error("已有 CLI 登录;切换账号请先 lmm logout,不会迁移应用授权")] AlreadyLoggedIn, - #[error("上次刷新未可靠完成;不会重放旧刷新令牌,请先 logout 再 login")] RefreshBlocked, - #[error("另一项 LMM 授权操作正在运行,或无法建立当前用户的安全锁")] Busy, - #[error("服务端返回未授权;请重新登录,或检查 lmm 客户端是否已部署")] Unauthorized, - #[error("新授权未能保存,且无法确认已撤销;请在 LMM 授权页检查 LMM CLI 授权")] UnstoredGrant, - #[error("无法输出登录入口;登录已停止")] Output, } +impl AuthError { + /// A fixed, non-sensitive description with a stable machine-readable key. + pub const fn message(&self) -> Message { + msg(match self { + Self::InvalidIssuer => "auth.invalid_issuer", + Self::Network => "auth.network", + Self::InvalidResponse => "auth.invalid_response", + Self::Denied => "auth.denied", + Self::Timeout => "auth.timeout", + Self::Callback => "auth.callback", + Self::CredentialStore => "auth.credential_store", + Self::NotLoggedIn => "auth.not_logged_in", + Self::AlreadyLoggedIn => "auth.already_logged_in", + Self::RefreshBlocked => "auth.refresh_blocked", + Self::Busy => "auth.busy", + Self::Unauthorized => "auth.unauthorized", + Self::UnstoredGrant => "auth.unstored_grant", + Self::Output => "auth.output", + }) + } +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.message(), formatter) + } +} + +impl std::error::Error for AuthError {} + + /// Login information deliberately excludes tokens, account secrets and scopes with group IDs. #[derive(Debug, Serialize)] pub struct LoginStatus { diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index 0e671f8..9f3422e 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -1,4 +1,5 @@ use super::AuthError; +use crate::i18n::text; use std::{ collections::BTreeMap, io::{Read, Write}, @@ -48,9 +49,9 @@ impl Callback { } let result = read_request(&mut stream, &host, issuer, state, remaining); let body = if result.is_ok() { - "Authorization received. Return to LMM CLI to check completion." + text("callback.received") } else { - "Invalid or denied authorization. Return to LMM CLI." + text("callback.invalid") }; let status = if result.is_ok() { "200 OK" diff --git a/src/plan.rs b/src/plan.rs index 76e4cce..821b363 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -2,6 +2,7 @@ use serde::Serialize; use thiserror::Error; +use crate::i18n::{Message, msg}; /// Authoritative selection read from the chosen software instance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,13 +44,13 @@ pub enum Activation { #[derive(Debug, Error, PartialEq, Eq)] pub enum PlanError { /// Unknown state must not be silently replaced with defaults. - #[error("无法确认当前服务商;需要先读取选定实例的实际配置")] + #[error("{}", msg("plan.unknown_selection"))] UnknownSelection, /// A revoked application must not be silently granted permission again. - #[error("应用授权已撤销;需要明确确认重新授权")] + #[error("{}", msg("plan.revoked_grant"))] RevokedGrant, /// Cost checks must use a server-enforced reservation. - #[error("付费验证需要明确同意、已知报价和服务端可执行的消费上限")] + #[error("{}", msg("plan.paid_verification"))] PaidVerificationNotAuthorized, } @@ -110,7 +111,7 @@ pub struct Preview { /// No discovered service is restarted. pub restart: bool, /// Specific missing prerequisites. - pub blockers: Vec<&'static str>, + pub blockers: Vec, } impl Preview { @@ -130,9 +131,9 @@ impl Preview { paid_test: false, restart: false, blockers: vec![ - "此版本尚无经过验证的目标软件写入适配器", - "CLI 登录只允许读取目录和余额;目标应用独立授权尚未接入", - "未验证软件版本、配置管理关系及实际调用路径", + msg("plan.write_adapter"), + msg("plan.application_grant"), + msg("plan.unverified_target"), ], } } diff --git a/src/provider.rs b/src/provider.rs index 742429a..ff03d81 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -1,6 +1,7 @@ //! Read-only built-in provider presets ported from Magpie's MIT data. //! No provider configuration, credentials, or network requests are accessed. +use crate::i18n::{Locale, Message, msg}; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; @@ -17,6 +18,16 @@ pub enum Kind { } impl Kind { + /// Localized category label; as_str and serialized values stay stable. + pub const fn label(self) -> Message { + msg(match self { + Self::Vendor => "kind.vendor", + Self::Relay => "kind.relay", + Self::Local => "kind.local", + Self::Remote => "kind.remote", + }) + } + pub fn as_str(self) -> &'static str { match self { Self::Vendor => "vendor", @@ -67,6 +78,9 @@ pub fn search(query: &str) -> Result { || preset.id.to_lowercase().contains(&query) || preset.name.to_lowercase().contains(&query) || preset.kind.as_str().contains(&query) + || Locale::ALL.iter().any(|locale| { + locale.text(preset.kind.label().key()).to_lowercase().contains(&query) + }) }) .collect(); Ok(Snapshot { diff --git a/src/restore.rs b/src/restore.rs index 09065e6..9712cd2 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -5,6 +5,7 @@ use serde_json::Value; use thiserror::Error; +use crate::i18n::msg; /// One scalar field owned by a particular LMM operation. /// This may contain secrets and therefore intentionally does not implement Debug or Serialize. @@ -21,16 +22,16 @@ pub struct FieldChange { #[derive(Debug, Error, PartialEq, Eq)] pub enum RestoreError { /// A newer user or manager edit takes precedence over the backup. - #[error("字段在 LMM 修改后再次变化,恢复已暂停")] + #[error("{}", msg("restore.conflict"))] Conflict, /// Object/array replacement would have an overly broad scope. - #[error("仅支持对象中的单个标量字段;集合需要专用适配器")] + #[error("{}", msg("restore.unsupported_shape"))] UnsupportedShape, /// Invalid JSON pointer or missing parent. - #[error("无法定位恢复字段的父对象")] + #[error("{}", msg("restore.invalid_pointer"))] InvalidPointer, /// A malformed or overlapping journal must not reorder writes. - #[error("变更记录包含重复字段")] + #[error("{}", msg("restore.duplicate_field"))] DuplicateField, } diff --git a/tests/i18n.rs b/tests/i18n.rs new file mode 100644 index 0000000..df17b42 --- /dev/null +++ b/tests/i18n.rs @@ -0,0 +1,181 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use lmm::i18n::Locale; +use serde_json::Value; +use std::{ffi::OsString, process::{Command, Output, Stdio}}; + +fn run(args: &[&str], variables: &[(&str, &str)]) -> Output { + let home = tempfile::tempdir().unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_lmm")); + command + .args(args) + .env("HOME", home.path()) + .env("USERPROFILE", home.path()) + .env("LOCALAPPDATA", home.path()) + .env("XDG_STATE_HOME", home.path()) + .env("PATH", "") + .env("NO_COLOR", "1") + .env("TERM", "dumb") + .stdin(Stdio::null()); + for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { + command.env_remove(key); + } + command.envs(variables.iter().copied()); + command.output().unwrap() +} + +fn stdout(output: &Output) -> &str { + std::str::from_utf8(&output.stdout).unwrap() +} + +fn detect(args: &[&str], variables: &[(&str, &str)]) -> Locale { + let args = args.iter().map(|value| OsString::from(*value)).collect::>(); + Locale::detect(&args, |key| variables.iter().find(|(name, _)| *name == key).map(|(_, value)| OsString::from(*value))) +} + +#[test] +fn recognizes_language_tags_without_confusing_traditional_chinese_with_simplified() { + for name in ["en", "EN-us", "en_GB.UTF-8", "C", "C.UTF-8", "POSIX"] { + assert_eq!(Locale::from_name(name), Some(Locale::En), "{name}"); + } + for name in ["zh", "zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh_CN@variant"] { + assert_eq!(Locale::from_name(name), Some(Locale::ZhCn), "{name}"); + } + for name in ["", "fr_FR.UTF-8", "zh-TW", "zh-Hant", "pirate"] { + assert_eq!(Locale::from_name(name), None, "{name}"); + } +} + +#[test] +fn language_precedence_and_fallback_are_deterministic() { + assert_eq!(detect(&["lmm", "--lang", "en", "catalog"], &[("LMM_LANG", "zh-CN")]), Locale::En); + assert_eq!(detect(&["lmm", "catalog", "--lang=zh-CN"], &[("LC_ALL", "C")]), Locale::ZhCn); + assert_eq!(detect(&["lmm"], &[("LMM_LANG", "zh-CN"), ("LC_ALL", "C")]), Locale::ZhCn); + assert_eq!(detect(&["lmm"], &[("LC_ALL", "C"), ("LANG", "zh-CN")]), Locale::En); + assert_eq!(detect(&["lmm"], &[("LC_MESSAGES", "zh_CN.UTF-8"), ("LANG", "en_US")]), Locale::ZhCn); + assert_eq!(detect(&["lmm"], &[("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")]), Locale::ZhCn); + assert_eq!(detect(&["lmm"], &[("LC_ALL", "fr_FR.UTF-8"), ("LANG", "zh-CN")]), Locale::En); + assert_eq!(detect(&["lmm"], &[]), Locale::En); +} + +#[test] +fn positional_data_after_double_dash_does_not_select_a_language() { + assert_eq!(detect(&["lmm", "presets", "--", "--lang=zh-CN"], &[]), Locale::En); + let output = run(&["presets", "--", "--lang=zh-CN"], &[]); + assert!(output.status.success()); + assert!(stdout(&output).contains("Built-in provider presets")); +} + +#[cfg(unix)] +#[test] +fn non_unicode_environment_does_not_panic_or_override_a_cli_choice() { + use std::os::unix::ffi::OsStringExt; + let args = vec![OsString::from("lmm")]; + assert_eq!(Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), Locale::En); + let args = vec![OsString::from("lmm"), OsString::from("--lang=zh-CN")]; + assert_eq!(Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), Locale::ZhCn); +} + +#[test] +fn root_and_subcommand_help_use_the_selected_language() { + for (language, heading) in [("en", "Usage:"), ("zh-CN", "用法:")] { + let root = run(&["--lang", language, "--help"], &[]); + assert!(root.status.success(), "{:?}", root.stderr); + assert!(stdout(&root).contains(heading)); + assert!(stdout(&root).contains("--lang")); + for name in ["presets", "catalog", "setup", "install", "login", "logout", "models", "status", "sync", "update", "doctor", "restore", "disconnect", "uninstall", "run"] { + let output = run(&[name, "--lang", language, "--help"], &[]); + assert!(output.status.success(), "{name}: {:?}", output.stderr); + assert!(stdout(&output).contains(heading), "{name}: {}", stdout(&output)); + assert!(stdout(&output).contains("--lang")); + assert!(!stdout(&output).contains("arg.")); + assert!(!stdout(&output).contains("cmd.")); + } + let help = run(&["--lang", language, "help", "login"], &[]); + assert!(help.status.success(), "{:?}", help.stderr); + assert!(stdout(&help).contains(heading)); + assert!(stdout(&help).contains("--issuer")); + } +} + +#[test] +fn environment_selection_and_global_flag_work_for_real_commands() { + let chinese = run(&["catalog", "astrbot"], &[("LANG", "zh_CN.UTF-8")]); + assert!(stdout(&chinese).contains("消息平台")); + let english = run(&["catalog", "astrbot", "--lang=en-US"], &[("LMM_LANG", "zh-CN")]); + assert!(stdout(&english).contains("messaging platforms")); + assert!(!stdout(&english).contains("消息平台")); + let chinese = run(&["--lang=zh_CN.UTF-8", "catalog", "astrbot"], &[("LC_ALL", "C")]); + assert!(stdout(&chinese).contains("消息平台")); +} + +#[test] +fn invalid_explicit_language_fails_without_running_a_command() { + for args in [vec!["--lang", "pirate", "catalog"], vec!["catalog", "--lang"], vec!["--lang=", "catalog"]] { + let output = run(&args, &[]); + assert_eq!(output.status.code(), Some(2)); + assert!(output.stdout.is_empty()); + } +} + +#[test] +fn runtime_errors_and_blocked_plans_are_translated() { + let english = run(&["--lang=en", "status", "unknown-software"], &[]); + let chinese = run(&["--lang=zh-CN", "status", "unknown-software"], &[]); + assert_eq!(english.status.code(), Some(2)); + assert_eq!(chinese.status.code(), Some(2)); + assert!(String::from_utf8_lossy(&english.stderr).contains("Unknown software ID")); + assert!(String::from_utf8_lossy(&chinese.stderr).contains("未知软件 ID")); + let chinese = run(&["setup", "astrbot", "--dry-run", "--lang=zh-CN"], &[]); + assert_eq!(chinese.status.code(), Some(3)); + assert!(stdout(&chinese).contains("计划已阻止")); + assert!(stdout(&chinese).contains("写入适配器")); + let chinese = run(&["models", "--issuer", "http://127.0.0.1", "--lang=zh-CN"], &[]); + assert_eq!(chinese.status.code(), Some(3)); + assert!(String::from_utf8_lossy(&chinese.stderr).contains("HTTPS 域名")); +} + +#[test] +fn json_messages_and_codes_do_not_change_with_the_display_language() { + for arguments in [ + vec!["catalog", "astrbot"], + vec!["presets", "local"], + vec!["status", "astrbot"], + vec!["doctor", "astrbot", "--report"], + vec!["setup", "astrbot", "--dry-run"], + vec!["status", "unknown-software"], + vec!["models", "--issuer", "http://127.0.0.1"], + vec!["login", "--non-interactive"], + ] { + let mut results = Vec::new(); + for language in ["en", "zh-CN"] { + let mut args = vec!["--json", "--lang", language]; + args.extend_from_slice(&arguments); + let output = run(&args, &[]); + assert!(output.stderr.is_empty(), "{args:?}: {:?}", output.stderr); + let mut value: Value = serde_json::from_slice(&output.stdout).unwrap(); + if let Some(object) = value.as_object_mut() { + object.remove("checked_at"); + } + results.push((output.status.code(), value)); + } + assert_eq!(results[0], results[1], "{arguments:?}"); + } + let output = run(&["status", "unknown-software", "--lang=zh-CN", "--json"], &[]); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value["error_code"], "error.unknown_software"); + assert!(value["error"].as_str().unwrap().starts_with("Unknown software ID")); +} + +#[test] +fn search_matches_translated_descriptions_without_changing_identifiers() { + for query in ["messaging platforms", " 消息平台 "] { + let output = run(&["catalog", query, "--json", "--lang=en"], &[]); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value.as_array().unwrap().len(), 1); + assert_eq!(value[0]["id"], "astrbot"); + } + let local = run(&["presets", "local", "--json"], &[]); + let translated = run(&["presets", "本地", "--json"], &[]); + assert_eq!(local.stdout, translated.stdout); +} From cc4857987804c4734b07dbd33ca7599c725a2e1c Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Fri, 16 Oct 2026 10:22:34 +0800 Subject: [PATCH 4/4] fix: make CLI checks enforce real failures --- .github/workflows/ci.yml | 8 ++- src/catalog.rs | 5 +- src/discovery.rs | 5 +- src/i18n.rs | 27 +++++--- src/main.rs | 123 ++++++++++++++++++---------------- src/oauth.rs | 3 +- src/plan.rs | 2 +- src/provider.rs | 5 +- src/restore.rs | 2 +- tests/i18n.rs | 140 +++++++++++++++++++++++++++++++++------ 10 files changed, 225 insertions(+), 95 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8db0f00..995ab17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,9 +48,13 @@ jobs: - name: Check translations run: python3 scripts/check-i18n.py - name: Run tests - run: cargo test --locked 2>&1 | tee "$RUNNER_TEMP/tests.log" + run: | + set -o pipefail + cargo test --locked 2>&1 | tee "$RUNNER_TEMP/tests.log" - name: Check lints - run: cargo clippy --locked --all-targets --all-features -- -D warnings 2>&1 | tee "$RUNNER_TEMP/clippy.log" + run: | + set -o pipefail + cargo clippy --locked --all-targets --all-features -- -D warnings 2>&1 | tee "$RUNNER_TEMP/clippy.log" - name: Verify source package run: cargo package --locked - name: Keep check logs diff --git a/src/catalog.rs b/src/catalog.rs index f845fb9..6a18b78 100644 --- a/src/catalog.rs +++ b/src/catalog.rs @@ -117,7 +117,10 @@ pub fn search(query: &str) -> Vec<&'static Software> { .iter() .any(|text| text.to_lowercase().contains(&query)) || Locale::ALL.iter().any(|locale| { - locale.text(item.purpose.key()).to_lowercase().contains(&query) + locale + .text(item.purpose.key()) + .to_lowercase() + .contains(&query) }) }) .collect() diff --git a/src/discovery.rs b/src/discovery.rs index 444cf32..72398a5 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -1,6 +1,9 @@ //! Bounded local probes. Finding a path is evidence, not proof of installation. -use crate::{catalog::Software, i18n::{Message, msg}}; +use crate::{ + catalog::Software, + i18n::{Message, msg}, +}; use serde::Serialize; use sha2::{Digest, Sha256}; use std::{ diff --git a/src/i18n.rs b/src/i18n.rs index eeffc4b..4280a13 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -60,10 +60,7 @@ impl Locale { } for key in ["LMM_LANG", "LC_ALL", "LC_MESSAGES", "LANG"] { if let Some(value) = get_env(key).filter(|value| !value.is_empty()) { - return value - .to_str() - .and_then(Self::from_name) - .unwrap_or_default(); + return value.to_str().and_then(Self::from_name).unwrap_or_default(); } } Self::default() @@ -178,7 +175,10 @@ mod tests { assert!(!reference.is_empty()); for locale in Locale::ALL { let catalog = locale.catalog(); - assert_eq!(reference.keys().collect::>(), catalog.keys().collect::>()); + assert_eq!( + reference.keys().collect::>(), + catalog.keys().collect::>() + ); assert!(catalog.values().all(|value| !value.trim().is_empty())); } } @@ -192,7 +192,12 @@ mod tests { fn replacement_values_cannot_inject_other_placeholders() { let output = msg("output.catalog_entry").render_in( Locale::En, - &[("id", "{source}"), ("name", "name"), ("purpose", "purpose"), ("source", "URL")], + &[ + ("id", "{source}"), + ("name", "name"), + ("purpose", "purpose"), + ("source", "URL"), + ], ); assert!(output.starts_with("{source} - name")); assert!(output.contains("Source: URL")); @@ -201,7 +206,13 @@ mod tests { #[test] fn serialization_does_not_depend_on_display_language() { let message = msg("error.unknown_software"); - assert_ne!(Locale::En.text(message.key()), Locale::ZhCn.text(message.key())); - assert_eq!(serde_json::to_value(message).unwrap(), Locale::En.text(message.key())); + assert_ne!( + Locale::En.text(message.key()), + Locale::ZhCn.text(message.key()) + ); + assert_eq!( + serde_json::to_value(message).unwrap(), + Locale::En.text(message.key()) + ); } } diff --git a/src/main.rs b/src/main.rs index 2728bd4..10ebc57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -138,7 +138,7 @@ struct DoctorArgs { #[arg(help = text("arg.fix"))] #[arg(long)] fix: bool, - #[arg(help = text("arg.report"))] + #[arg(long, help = text("arg.report"))] report: bool, } @@ -212,8 +212,7 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result { - let software = catalog::find(id) - .ok_or(msg("error.unknown_software"))?; + let software = catalog::find(id).ok_or(msg("error.unknown_software"))?; if target.instance.is_some() && !matches!(id, "astrbot" | "cc-switch") { return Err(msg("error.instance_unsupported")); } @@ -232,8 +231,7 @@ fn inspect(target: &TargetArgs, context: &ProbeContext) -> Result Result { let mut output = io::stderr().lock(); - writeln!(output, "{}", msg("select.title")) - .map_err(|_| msg("error.menu_output"))?; + writeln!(output, "{}", msg("select.title")).map_err(|_| msg("error.menu_output"))?; for (i, software) in catalog::SOFTWARE.iter().enumerate() { writeln!(output, " {}. {} ({})", i + 1, software.name, software.id) .map_err(|_| msg("error.menu_output"))?; @@ -295,22 +293,16 @@ fn operation_report( report.plans.push(preview); } if args.all { - report.notes.push( - msg("note.all_blocked"), - ); + report.notes.push(msg("note.all_blocked")); } if args.dry_run { - report - .notes - .push(msg("note.dry_run")); + report.notes.push(msg("note.dry_run")); } if args.yes { report.notes.push(msg("note.yes")); } if name == "install" { - report - .notes - .push(msg("note.install")); + report.notes.push(msg("note.install")); } Ok(report) } @@ -332,24 +324,40 @@ fn emit(report: &Report, json: bool) -> Result<(), Message> { report.command, report.environment.os, report.environment.arch ) .map_err(|_| msg("error.output"))?; - writeln!(output, "{}", msg("output.scope").render(&[("time", &report.checked_at.to_string())])) - .map_err(|_| msg("error.output"))?; + writeln!( + output, + "{}", + msg("output.scope").render(&[("time", &report.checked_at.to_string())]) + ) + .map_err(|_| msg("error.output"))?; for target in &report.targets { - writeln!(output, "{}", msg("output.target").render(&[("software", target.software)])) - .map_err(|_| msg("error.output"))?; + writeln!( + output, + "{}", + msg("output.target").render(&[("software", target.software)]) + ) + .map_err(|_| msg("error.output"))?; for evidence in &target.evidence { // Debug formatting still escapes control characters in user-controlled paths. let path = format!("{:?}", evidence.path); let observation = evidence.observation.label(); - writeln!(output, "{}", msg("output.evidence").render(&[("path", &path), ("observation", observation.text())])) - .map_err(|_| msg("error.output"))?; - } - writeln!(output, "{}", msg("output.verification")) + writeln!( + output, + "{}", + msg("output.evidence") + .render(&[("path", &path), ("observation", observation.text())]) + ) .map_err(|_| msg("error.output"))?; + } + writeln!(output, "{}", msg("output.verification")).map_err(|_| msg("error.output"))?; } for plan in &report.plans { - writeln!(output, "{}", msg("output.blocked_plan").render(&[("software", plan.software)])) - .map_err(|_| msg("error.output"))?; + writeln!( + output, + "{}", + msg("output.blocked_plan").render(&[("software", plan.software)]) + ) + .map_err(|_| msg("error.output"))?; for blocker in &plan.blockers { writeln!(output, " - {blocker}").map_err(|_| msg("error.output"))?; } @@ -408,10 +416,17 @@ fn execute(cli: &Cli) -> Result { } else { let mut output = io::stdout().lock(); for software in entries { - writeln!(output, "{}", msg("output.catalog_entry").render(&[ - ("id", software.id), ("name", software.name), - ("purpose", software.purpose.text()), ("source", software.source), - ])).map_err(|_| msg("error.catalog_output"))?; + writeln!( + output, + "{}", + msg("output.catalog_entry").render(&[ + ("id", software.id), + ("name", software.name), + ("purpose", software.purpose.text()), + ("source", software.source), + ]) + ) + .map_err(|_| msg("error.catalog_output"))?; } } return Ok(0); @@ -423,9 +438,7 @@ fn execute(cli: &Cli) -> Result { report.notes.push(msg("note.doctor_incomplete")); if args.fix { report.outcome = "blocked"; - report - .notes - .push(msg("note.fix_blocked")); + report.notes.push(msg("note.fix_blocked")); } if args.report { report.redact(); @@ -468,7 +481,8 @@ fn execute(cli: &Cli) -> Result { let mut output = io::stderr().lock(); writeln!( output, - "{}", msg("output.authorization_url").render(&[("url", url)]) + "{}", + msg("output.authorization_url").render(&[("url", url)]) ) .map_err(|_| oauth::AuthError::Output)?; if !args.no_browser && open::that_detached(url).is_err() { @@ -499,11 +513,8 @@ fn execute(cli: &Cli) -> Result { &serde_json::json!({"outcome": outcome, "applications_changed": false, "other_application_grants": "not_checked"}), )?; } else { - writeln!( - io::stdout().lock(), - "{}", msg("output.logout") - ) - .map_err(|_| msg("error.logout_output"))?; + writeln!(io::stdout().lock(), "{}", msg("output.logout")) + .map_err(|_| msg("error.logout_output"))?; } Ok(0) } @@ -517,23 +528,25 @@ fn execute(cli: &Cli) -> Result { write_json(&catalog)?; } else { let mut output = io::stdout().lock(); - writeln!( - output, - "{}", msg("output.models") - ) - .map_err(|_| msg("error.models_output"))?; + writeln!(output, "{}", msg("output.models")) + .map_err(|_| msg("error.models_output"))?; for model in &catalog.models { - writeln!(output, "{}", msg("output.model_entry").render(&[ - ("id", &format!("{:?}", model.id)), - ("upstream", &format!("{:?}", model.upstream_model)), - ("group", &format!("{:?}", model.group)), - ("currency", &format!("{:?}", model.pricing.currency)), - ("unit", &format!("{:?}", model.pricing.unit)), - ("input", &format!("{:?}", model.pricing.input)), - ("output", &format!("{:?}", model.pricing.output)), - ("request", &format!("{:?}", model.pricing.request)), - ("basis", &format!("{:?}", model.pricing.price_basis)), - ])).map_err(|_| msg("error.models_output"))?; + writeln!( + output, + "{}", + msg("output.model_entry").render(&[ + ("id", &format!("{:?}", model.id)), + ("upstream", &format!("{:?}", model.upstream_model)), + ("group", &format!("{:?}", model.group)), + ("currency", &format!("{:?}", model.pricing.currency)), + ("unit", &format!("{:?}", model.pricing.unit)), + ("input", &format!("{:?}", model.pricing.input)), + ("output", &format!("{:?}", model.pricing.output)), + ("request", &format!("{:?}", model.pricing.request)), + ("basis", &format!("{:?}", model.pricing.price_basis)), + ]) + ) + .map_err(|_| msg("error.models_output"))?; } } Ok(0) @@ -553,9 +566,7 @@ fn execute(cli: &Cli) -> Result { }; let mut report = Report::new(name, inspect(&target, &context)?); report.outcome = "blocked"; - report - .notes - .push(msg("note.execution_blocked")); + report.notes.push(msg("note.execution_blocked")); report } }; diff --git a/src/oauth.rs b/src/oauth.rs index 8010276..e4e1b74 100644 --- a/src/oauth.rs +++ b/src/oauth.rs @@ -7,6 +7,7 @@ mod storage; #[cfg(test)] mod tests; +use crate::i18n::{Message, msg}; use protocol::{Credential, TokenResponse}; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; @@ -15,7 +16,6 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; use storage::{SecretStore, StoredSession, SystemStore}; -use crate::i18n::{Message, msg}; use zeroize::Zeroizing; /// Default trusted deployment issuer; never taken from HTTP redirects. @@ -72,7 +72,6 @@ impl std::fmt::Display for AuthError { impl std::error::Error for AuthError {} - /// Login information deliberately excludes tokens, account secrets and scopes with group IDs. #[derive(Debug, Serialize)] pub struct LoginStatus { diff --git a/src/plan.rs b/src/plan.rs index 821b363..4668353 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -1,8 +1,8 @@ //! Policy decisions shared by future adapters; discovery never authorizes a write. +use crate::i18n::{Message, msg}; use serde::Serialize; use thiserror::Error; -use crate::i18n::{Message, msg}; /// Authoritative selection read from the chosen software instance. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src/provider.rs b/src/provider.rs index ff03d81..4033323 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -79,7 +79,10 @@ pub fn search(query: &str) -> Result { || preset.name.to_lowercase().contains(&query) || preset.kind.as_str().contains(&query) || Locale::ALL.iter().any(|locale| { - locale.text(preset.kind.label().key()).to_lowercase().contains(&query) + locale + .text(preset.kind.label().key()) + .to_lowercase() + .contains(&query) }) }) .collect(); diff --git a/src/restore.rs b/src/restore.rs index 9712cd2..54b3ac7 100644 --- a/src/restore.rs +++ b/src/restore.rs @@ -3,9 +3,9 @@ //! Arrays and secrets require adapter-specific ownership; broad object replacement //! is deliberately rejected. A restore never renews any authorization. +use crate::i18n::msg; use serde_json::Value; use thiserror::Error; -use crate::i18n::msg; /// One scalar field owned by a particular LMM operation. /// This may contain secrets and therefore intentionally does not implement Debug or Serialize. diff --git a/tests/i18n.rs b/tests/i18n.rs index df17b42..c1ffc02 100644 --- a/tests/i18n.rs +++ b/tests/i18n.rs @@ -2,7 +2,10 @@ use lmm::i18n::Locale; use serde_json::Value; -use std::{ffi::OsString, process::{Command, Output, Stdio}}; +use std::{ + ffi::OsString, + process::{Command, Output, Stdio}, +}; fn run(args: &[&str], variables: &[(&str, &str)]) -> Output { let home = tempfile::tempdir().unwrap(); @@ -29,8 +32,16 @@ fn stdout(output: &Output) -> &str { } fn detect(args: &[&str], variables: &[(&str, &str)]) -> Locale { - let args = args.iter().map(|value| OsString::from(*value)).collect::>(); - Locale::detect(&args, |key| variables.iter().find(|(name, _)| *name == key).map(|(_, value)| OsString::from(*value))) + let args = args + .iter() + .map(|value| OsString::from(*value)) + .collect::>(); + Locale::detect(&args, |key| { + variables + .iter() + .find(|(name, _)| *name == key) + .map(|(_, value)| OsString::from(*value)) + }) } #[test] @@ -38,7 +49,15 @@ fn recognizes_language_tags_without_confusing_traditional_chinese_with_simplifie for name in ["en", "EN-us", "en_GB.UTF-8", "C", "C.UTF-8", "POSIX"] { assert_eq!(Locale::from_name(name), Some(Locale::En), "{name}"); } - for name in ["zh", "zh-CN", "zh_CN.UTF-8", "zh_SG", "zh-Hans", "zh-Hans-CN", "zh_CN@variant"] { + for name in [ + "zh", + "zh-CN", + "zh_CN.UTF-8", + "zh_SG", + "zh-Hans", + "zh-Hans-CN", + "zh_CN@variant", + ] { assert_eq!(Locale::from_name(name), Some(Locale::ZhCn), "{name}"); } for name in ["", "fr_FR.UTF-8", "zh-TW", "zh-Hant", "pirate"] { @@ -48,19 +67,49 @@ fn recognizes_language_tags_without_confusing_traditional_chinese_with_simplifie #[test] fn language_precedence_and_fallback_are_deterministic() { - assert_eq!(detect(&["lmm", "--lang", "en", "catalog"], &[("LMM_LANG", "zh-CN")]), Locale::En); - assert_eq!(detect(&["lmm", "catalog", "--lang=zh-CN"], &[("LC_ALL", "C")]), Locale::ZhCn); - assert_eq!(detect(&["lmm"], &[("LMM_LANG", "zh-CN"), ("LC_ALL", "C")]), Locale::ZhCn); - assert_eq!(detect(&["lmm"], &[("LC_ALL", "C"), ("LANG", "zh-CN")]), Locale::En); - assert_eq!(detect(&["lmm"], &[("LC_MESSAGES", "zh_CN.UTF-8"), ("LANG", "en_US")]), Locale::ZhCn); - assert_eq!(detect(&["lmm"], &[("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")]), Locale::ZhCn); - assert_eq!(detect(&["lmm"], &[("LC_ALL", "fr_FR.UTF-8"), ("LANG", "zh-CN")]), Locale::En); + assert_eq!( + detect( + &["lmm", "--lang", "en", "catalog"], + &[("LMM_LANG", "zh-CN")] + ), + Locale::En + ); + assert_eq!( + detect(&["lmm", "catalog", "--lang=zh-CN"], &[("LC_ALL", "C")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LMM_LANG", "zh-CN"), ("LC_ALL", "C")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LC_ALL", "C"), ("LANG", "zh-CN")]), + Locale::En + ); + assert_eq!( + detect( + &["lmm"], + &[("LC_MESSAGES", "zh_CN.UTF-8"), ("LANG", "en_US")] + ), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LMM_LANG", ""), ("LANG", "zh_CN.UTF-8")]), + Locale::ZhCn + ); + assert_eq!( + detect(&["lmm"], &[("LC_ALL", "fr_FR.UTF-8"), ("LANG", "zh-CN")]), + Locale::En + ); assert_eq!(detect(&["lmm"], &[]), Locale::En); } #[test] fn positional_data_after_double_dash_does_not_select_a_language() { - assert_eq!(detect(&["lmm", "presets", "--", "--lang=zh-CN"], &[]), Locale::En); + assert_eq!( + detect(&["lmm", "presets", "--", "--lang=zh-CN"], &[]), + Locale::En + ); let output = run(&["presets", "--", "--lang=zh-CN"], &[]); assert!(output.status.success()); assert!(stdout(&output).contains("Built-in provider presets")); @@ -71,9 +120,15 @@ fn positional_data_after_double_dash_does_not_select_a_language() { fn non_unicode_environment_does_not_panic_or_override_a_cli_choice() { use std::os::unix::ffi::OsStringExt; let args = vec![OsString::from("lmm")]; - assert_eq!(Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), Locale::En); + assert_eq!( + Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), + Locale::En + ); let args = vec![OsString::from("lmm"), OsString::from("--lang=zh-CN")]; - assert_eq!(Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), Locale::ZhCn); + assert_eq!( + Locale::detect(&args, |_| Some(OsString::from_vec(vec![0xff]))), + Locale::ZhCn + ); } #[test] @@ -83,10 +138,30 @@ fn root_and_subcommand_help_use_the_selected_language() { assert!(root.status.success(), "{:?}", root.stderr); assert!(stdout(&root).contains(heading)); assert!(stdout(&root).contains("--lang")); - for name in ["presets", "catalog", "setup", "install", "login", "logout", "models", "status", "sync", "update", "doctor", "restore", "disconnect", "uninstall", "run"] { + for name in [ + "presets", + "catalog", + "setup", + "install", + "login", + "logout", + "models", + "status", + "sync", + "update", + "doctor", + "restore", + "disconnect", + "uninstall", + "run", + ] { let output = run(&[name, "--lang", language, "--help"], &[]); assert!(output.status.success(), "{name}: {:?}", output.stderr); - assert!(stdout(&output).contains(heading), "{name}: {}", stdout(&output)); + assert!( + stdout(&output).contains(heading), + "{name}: {}", + stdout(&output) + ); assert!(stdout(&output).contains("--lang")); assert!(!stdout(&output).contains("arg.")); assert!(!stdout(&output).contains("cmd.")); @@ -102,16 +177,26 @@ fn root_and_subcommand_help_use_the_selected_language() { fn environment_selection_and_global_flag_work_for_real_commands() { let chinese = run(&["catalog", "astrbot"], &[("LANG", "zh_CN.UTF-8")]); assert!(stdout(&chinese).contains("消息平台")); - let english = run(&["catalog", "astrbot", "--lang=en-US"], &[("LMM_LANG", "zh-CN")]); + let english = run( + &["catalog", "astrbot", "--lang=en-US"], + &[("LMM_LANG", "zh-CN")], + ); assert!(stdout(&english).contains("messaging platforms")); assert!(!stdout(&english).contains("消息平台")); - let chinese = run(&["--lang=zh_CN.UTF-8", "catalog", "astrbot"], &[("LC_ALL", "C")]); + let chinese = run( + &["--lang=zh_CN.UTF-8", "catalog", "astrbot"], + &[("LC_ALL", "C")], + ); assert!(stdout(&chinese).contains("消息平台")); } #[test] fn invalid_explicit_language_fails_without_running_a_command() { - for args in [vec!["--lang", "pirate", "catalog"], vec!["catalog", "--lang"], vec!["--lang=", "catalog"]] { + for args in [ + vec!["--lang", "pirate", "catalog"], + vec!["catalog", "--lang"], + vec!["--lang=", "catalog"], + ] { let output = run(&args, &[]); assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); @@ -130,7 +215,10 @@ fn runtime_errors_and_blocked_plans_are_translated() { assert_eq!(chinese.status.code(), Some(3)); assert!(stdout(&chinese).contains("计划已阻止")); assert!(stdout(&chinese).contains("写入适配器")); - let chinese = run(&["models", "--issuer", "http://127.0.0.1", "--lang=zh-CN"], &[]); + let chinese = run( + &["models", "--issuer", "http://127.0.0.1", "--lang=zh-CN"], + &[], + ); assert_eq!(chinese.status.code(), Some(3)); assert!(String::from_utf8_lossy(&chinese.stderr).contains("HTTPS 域名")); } @@ -161,10 +249,18 @@ fn json_messages_and_codes_do_not_change_with_the_display_language() { } assert_eq!(results[0], results[1], "{arguments:?}"); } - let output = run(&["status", "unknown-software", "--lang=zh-CN", "--json"], &[]); + let output = run( + &["status", "unknown-software", "--lang=zh-CN", "--json"], + &[], + ); let value: Value = serde_json::from_slice(&output.stdout).unwrap(); assert_eq!(value["error_code"], "error.unknown_software"); - assert!(value["error"].as_str().unwrap().starts_with("Unknown software ID")); + assert!( + value["error"] + .as_str() + .unwrap() + .starts_with("Unknown software ID") + ); } #[test]