Skip to content

Commit cb026b3

Browse files
montfortclaude
andauthored
feat(cli): OS locale auto-detect + bump cli-3.5.0 (#61)
Closes the language-aware explore series started in 3.4.0. Adds the final missing fallback: when a project has no .devtrail/config.yml, devtrail explore / new / status now read \$LC_ALL / \$LANG and map a POSIX locale (e.g. zh_CN.UTF-8, es_MX.UTF-8) onto the nearest supported language. Existing projects with a config file are unaffected — an explicit language: en is still treated as a deliberate user choice and never overridden by env vars. New utils::detect_os_locale + parse_posix_locale handle the parsing. Traditional Chinese (zh_TW / zh_HK) returns None on purpose because DevTrail only ships Simplified. DevTrailConfig::resolve_language is now the single entry point used by all three commands, so they agree on the effective language. Resolution order: --lang > config.language (when file exists) > OS locale > "en". Bumps version 3.4.1 → 3.5.0. CHANGELOG entry covers all three pendientes shipped in this minor (translated TUI shell, live language switcher, locale auto-detect). 6 docs updated: 3 README versioning tables and 3 CLI-REFERENCE.md (EN/ES/zh-CN) with the new resolution order and the L keybind. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ccc8a7b commit cb026b3

14 files changed

Lines changed: 226 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,20 @@ and this project uses [independent versioning](README.md#versioning) for Framewo
77

88
---
99

10+
## CLI 3.5.0 — TUI i18n Polish: Translated Shell, Live Switcher, Locale Auto-Detect
11+
12+
Three changes that complete the language-aware `devtrail explore` work started in 3.4.0. Together they make the TUI feel native to non-English users instead of just "translated docs inside an English shell."
13+
14+
### Added (CLI)
15+
- **Translated TUI shell**: navigation tree group/subgroup labels, sort hints, status-bar key hints, notifications, and the `?` help popup all render in `es` and `zh-CN` when the active language is non-English. Untranslated strings fall back to English silently. New module `cli/src/tui/i18n_strings.rs` is the single lookup point — extending to a new locale is one entry per call site.
16+
- **Live language switcher**: press `L` inside `devtrail explore` to cycle the display language (`en → es → zh-CN → en`) without quitting. The doc index is rebuilt on the spot, the document body cache is dropped so the next open reads the localized file, and the status bar shows a translated notification (`Idioma: es`, `语言: zh-CN`). Documented in the help popup.
17+
- **OS locale auto-detection**: when a project has no `.devtrail/config.yml`, `devtrail explore` / `new` / `status` now read `$LC_ALL` / `$LANG` and map a POSIX locale (e.g., `zh_CN.UTF-8`, `es_MX.UTF-8`) to the nearest supported language. Existing projects with a config file are unaffected — an explicit `language: en` is still treated as a deliberate user choice and never overridden by env vars. Traditional Chinese (`zh_TW`, `zh_HK`) returns `None` because DevTrail only ships Simplified.
18+
19+
### Changed (CLI)
20+
- New `DevTrailConfig::resolve_language(project_root)` is now the single entry point used by `explore`, `new`, and `status`, so all three commands agree on the effective language. Resolution order: `--lang` flag > `config.language` (when config file exists) > OS locale > `"en"`.
21+
22+
---
23+
1024
## CLI 3.4.1 — Code-Block Background No Longer Fragments on Narrow Panels
1125

1226
### Fixed (CLI)

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ DevTrail uses independent version tags for each component:
207207
| Component | Tag prefix | Example | Includes |
208208
|-----------|-----------|---------|----------|
209209
| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance, directives |
210-
| CLI | `cli-` | `cli-3.4.1` | The `devtrail` binary |
210+
| CLI | `cli-` | `cli-3.5.0` | The `devtrail` binary |
211211

212212
Check installed versions with `devtrail status` or `devtrail about`.
213213

cli/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "devtrail-cli"
3-
version = "3.4.1"
3+
version = "3.5.0"
44
edition = "2021"
55
description = "CLI tool for DevTrail - Documentation Governance for AI-Assisted Development"
66
license = "MIT"

cli/src/commands/explore.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,11 @@ pub fn run(path: &str, lang_override: Option<&str>) -> Result<()> {
1313
}
1414
};
1515

16-
// Effective language: --lang flag > config.language > "en".
16+
// Effective language: --lang flag > config.language (when config
17+
// exists) > $LC_ALL / $LANG > "en".
1718
let language = match lang_override {
1819
Some(l) => l.to_string(),
19-
None => DevTrailConfig::load(&resolved.path)
20-
.map(|c| c.language)
21-
.unwrap_or_else(|_| "en".to_string()),
20+
None => DevTrailConfig::resolve_language(&resolved.path),
2221
};
2322

2423
crate::tui::run(&resolved.path, resolved.is_fallback, &language)

cli/src/commands/new.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ pub fn run(path: &str, doc_type_arg: Option<&str>, title_arg: Option<&str>) -> R
1515
let devtrail_dir = target.join(".devtrail");
1616

1717
let config = DevTrailConfig::load(&target).unwrap_or_default();
18-
let lang = &config.language;
18+
let resolved_language = DevTrailConfig::resolve_language(&target);
19+
let lang = resolved_language.as_str();
1920
let china = config.has_region("china");
2021

2122
// Select document type

cli/src/commands/status.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -272,13 +272,10 @@ fn load_version(project_root: &std::path::Path) -> String {
272272
}
273273

274274
fn load_language(project_root: &std::path::Path) -> String {
275-
match DevTrailConfig::load(project_root) {
276-
Ok(c) => c.language,
277-
Err(_) => {
278-
utils::warn("Could not read config.yml");
279-
"unknown".to_string()
280-
}
281-
}
275+
// Use the same resolver as `explore` / `new` so all three commands
276+
// agree on the effective language (config when present, else OS locale,
277+
// else "en").
278+
DevTrailConfig::resolve_language(project_root)
282279
}
283280

284281
fn count_documents(devtrail_dir: &std::path::Path) -> Vec<(&'static str, &'static str, usize)> {

cli/src/config.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,30 @@ impl DevTrailConfig {
7575
.iter()
7676
.any(|r| r.eq_ignore_ascii_case(region))
7777
}
78+
79+
/// Resolve the effective display language for a project, applying all
80+
/// fallbacks in order:
81+
///
82+
/// 1. If `.devtrail/config.yml` exists on disk, the value of its
83+
/// `language` key (defaulting to `"en"` when the field is absent).
84+
/// A configured value — even the default `"en"` — is treated as an
85+
/// explicit choice and is never overridden by env vars.
86+
/// 2. If no config file exists, parse `$LC_ALL` / `$LANG` and map it
87+
/// onto a supported locale (`en`, `es`, `zh-CN`).
88+
/// 3. Final fallback: `"en"`.
89+
///
90+
/// This is the single entry point used by `devtrail explore`,
91+
/// `devtrail new`, and `devtrail status` so they all agree on which
92+
/// language to use.
93+
pub fn resolve_language(project_root: &Path) -> String {
94+
let config_path = project_root.join(".devtrail/config.yml");
95+
if config_path.exists() {
96+
return Self::load(project_root)
97+
.map(|c| c.language)
98+
.unwrap_or_else(|_| default_language());
99+
}
100+
crate::utils::detect_os_locale().unwrap_or_else(default_language)
101+
}
78102
}
79103

80104
#[cfg(test)]
@@ -100,6 +124,53 @@ mod tests {
100124
assert!(cfg.has_region("global"));
101125
assert!(!cfg.has_region("eu"));
102126
}
127+
128+
#[test]
129+
fn resolve_language_uses_config_value_when_present() {
130+
let tmp = tempfile::TempDir::new().unwrap();
131+
let dt = tmp.path().join(".devtrail");
132+
std::fs::create_dir_all(&dt).unwrap();
133+
std::fs::write(dt.join("config.yml"), "language: zh-CN\n").unwrap();
134+
135+
// Even if $LANG is set to something else, the file's explicit
136+
// value must win — config is treated as a deliberate user choice.
137+
let prev = std::env::var("LANG").ok();
138+
unsafe { std::env::set_var("LANG", "fr_FR.UTF-8"); }
139+
let lang = DevTrailConfig::resolve_language(tmp.path());
140+
if let Some(p) = prev {
141+
unsafe { std::env::set_var("LANG", p); }
142+
} else {
143+
unsafe { std::env::remove_var("LANG"); }
144+
}
145+
assert_eq!(lang, "zh-CN");
146+
}
147+
148+
#[test]
149+
fn resolve_language_falls_back_to_default_when_no_config_no_env() {
150+
let tmp = tempfile::TempDir::new().unwrap();
151+
// No .devtrail/config.yml in tmp.
152+
// Clear env vars so the OS locale path can't return a real value.
153+
let prev_all = std::env::var("LC_ALL").ok();
154+
let prev_lang = std::env::var("LANG").ok();
155+
unsafe {
156+
std::env::remove_var("LC_ALL");
157+
std::env::set_var("LANG", "C");
158+
}
159+
let lang = DevTrailConfig::resolve_language(tmp.path());
160+
// Restore env.
161+
unsafe {
162+
if let Some(p) = prev_all {
163+
std::env::set_var("LC_ALL", p);
164+
}
165+
if let Some(p) = prev_lang {
166+
std::env::set_var("LANG", p);
167+
} else {
168+
std::env::remove_var("LANG");
169+
}
170+
}
171+
// "C" maps to "en", so the result is "en".
172+
assert_eq!(lang, "en");
173+
}
103174
}
104175

105176
/// Checksums tracking file for detecting user modifications

cli/src/utils.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,40 @@ pub fn resolve_project_root(path: &str) -> Option<ResolvedPath> {
104104
None
105105
}
106106

107+
/// Read `$LC_ALL` (preferred when set) or `$LANG` and map a POSIX locale
108+
/// string like `zh_CN.UTF-8` or `es_MX` to one of the languages DevTrail
109+
/// supports (`en`, `es`, `zh-CN`). Returns `None` when no env var is set
110+
/// or when the territory points at an unsupported variant (e.g.,
111+
/// Traditional Chinese in `zh_TW` / `zh_HK`). Callers fall back to `"en"`.
112+
pub fn detect_os_locale() -> Option<String> {
113+
let raw = std::env::var("LC_ALL")
114+
.ok()
115+
.filter(|v| !v.is_empty())
116+
.or_else(|| std::env::var("LANG").ok().filter(|v| !v.is_empty()))?;
117+
parse_posix_locale(&raw)
118+
}
119+
120+
/// Parse a POSIX locale string (e.g. `zh_CN.UTF-8`, `es`, `C`) and map it
121+
/// to a DevTrail-supported language code. Public for unit testing.
122+
pub fn parse_posix_locale(raw: &str) -> Option<String> {
123+
// Strip charset (`.UTF-8`) and modifier (`@euro`) suffixes first.
124+
let trimmed = raw.split('.').next()?.split('@').next()?;
125+
if trimmed.is_empty() {
126+
return None;
127+
}
128+
let mut parts = trimmed.splitn(2, '_');
129+
let lang = parts.next()?;
130+
let territory = parts.next();
131+
match (lang, territory) {
132+
("zh", Some("CN")) | ("zh", Some("SG")) | ("zh", None) => Some("zh-CN".to_string()),
133+
// Traditional Chinese (TW / HK / MO) — DevTrail only ships zh-CN.
134+
("zh", _) => None,
135+
("es", _) => Some("es".to_string()),
136+
("en", _) | ("C", _) | ("POSIX", _) => Some("en".to_string()),
137+
_ => None,
138+
}
139+
}
140+
107141
/// Resolve `<dir>/<filename>` honoring an optional translation under
108142
/// `<dir>/i18n/<lang>/<filename>`. When `lang` is `"en"` (or any value where
109143
/// the localized variant is absent), returns the root path unchanged. This is
@@ -267,6 +301,54 @@ mod tests {
267301
assert_eq!(resolved, dir.join("FOO.md"));
268302
}
269303

304+
#[test]
305+
fn parse_posix_locale_zh_cn() {
306+
assert_eq!(parse_posix_locale("zh_CN.UTF-8"), Some("zh-CN".into()));
307+
assert_eq!(parse_posix_locale("zh_CN"), Some("zh-CN".into()));
308+
assert_eq!(parse_posix_locale("zh_SG.UTF-8"), Some("zh-CN".into()));
309+
// Bare "zh" with no territory: assume Simplified.
310+
assert_eq!(parse_posix_locale("zh"), Some("zh-CN".into()));
311+
}
312+
313+
#[test]
314+
fn parse_posix_locale_traditional_chinese_unsupported() {
315+
// We don't ship Traditional translations — those should fall back
316+
// through to "en" via the caller's None handling, not silently
317+
// claim Simplified.
318+
assert_eq!(parse_posix_locale("zh_TW.UTF-8"), None);
319+
assert_eq!(parse_posix_locale("zh_HK.UTF-8"), None);
320+
}
321+
322+
#[test]
323+
fn parse_posix_locale_spanish_any_territory() {
324+
assert_eq!(parse_posix_locale("es_MX.UTF-8"), Some("es".into()));
325+
assert_eq!(parse_posix_locale("es_ES"), Some("es".into()));
326+
assert_eq!(parse_posix_locale("es_AR.UTF-8"), Some("es".into()));
327+
}
328+
329+
#[test]
330+
fn parse_posix_locale_english_and_pseudo() {
331+
assert_eq!(parse_posix_locale("en_US.UTF-8"), Some("en".into()));
332+
assert_eq!(parse_posix_locale("en"), Some("en".into()));
333+
assert_eq!(parse_posix_locale("C"), Some("en".into()));
334+
assert_eq!(parse_posix_locale("POSIX"), Some("en".into()));
335+
}
336+
337+
#[test]
338+
fn parse_posix_locale_unsupported_returns_none() {
339+
// French isn't translated yet; caller must fall back to "en".
340+
assert_eq!(parse_posix_locale("fr_FR.UTF-8"), None);
341+
assert_eq!(parse_posix_locale("ja_JP.UTF-8"), None);
342+
assert_eq!(parse_posix_locale(""), None);
343+
}
344+
345+
#[test]
346+
fn parse_posix_locale_strips_charset_and_modifier() {
347+
// `@modifier` after the charset (or before it) appears in some
348+
// locales; we strip whatever follows the first `@` or `.`.
349+
assert_eq!(parse_posix_locale("es_ES@euro"), Some("es".into()));
350+
}
351+
270352
#[test]
271353
fn resolve_localized_path_for_english_skips_lookup() {
272354
let tmp = tempfile::TempDir::new().unwrap();

docs/adopters/CLI-REFERENCE.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ DevTrail uses **independent version tags** for each component:
4949
| Component | Tag prefix | Example | What it includes |
5050
|-----------|-----------|---------|------------------|
5151
| Framework | `fw-` | `fw-4.3.0` | Templates (12 types), governance docs, directives |
52-
| CLI | `cli-` | `cli-3.4.1` | The `devtrail` binary |
52+
| CLI | `cli-` | `cli-3.5.0` | The `devtrail` binary |
5353

5454
Framework and CLI are released independently. A framework update does not require a CLI update, and vice versa.
5555

@@ -110,7 +110,7 @@ $ devtrail update
110110
Updating framework...
111111
✔ Framework updated to fw-4.3.0
112112
Updating CLI...
113-
✔ CLI updated to cli-3.4.1
113+
✔ CLI updated to cli-3.5.0
114114
```
115115

116116
---
@@ -143,11 +143,11 @@ Use `--method` to override auto-detection: `--method=github` or `--method=cargo`
143143

144144
```bash
145145
$ devtrail update-cli
146-
✔ CLI updated to cli-3.4.1
146+
✔ CLI updated to cli-3.5.0
147147

148148
$ devtrail update-cli --method=cargo
149149
Compiling from source, this may take a few minutes...
150-
✔ CLI updated to cli-3.4.1
150+
✔ CLI updated to cli-3.5.0
151151
```
152152

153153
---
@@ -210,7 +210,7 @@ $ devtrail status
210210
┌───────────┬──────────────────────────┐
211211
│ Path │ /home/user/my-project │
212212
│ Framework │ fw-4.3.0 │
213-
│ CLI │ cli-3.4.1
213+
│ CLI │ cli-3.5.0
214214
│ Language │ en │
215215
└───────────┴──────────────────────────┘
216216
@@ -641,7 +641,14 @@ Browse and read DevTrail documentation interactively in a terminal UI.
641641

642642
| Flag | Default | Description |
643643
|------|---------|-------------|
644-
| `--lang <code>` | `language` from `.devtrail/config.yml`, else `en` | Display language for framework governance docs (`en`, `es`, `zh-CN`). Falls back silently to English when a translation is missing. |
644+
| `--lang <code>` | resolved from project (see below) | Display language for the TUI shell and framework governance docs (`en`, `es`, `zh-CN`). Falls back silently to English when a translation is missing. |
645+
646+
**Language resolution order** (since cli-3.5.0):
647+
648+
1. `--lang <code>` flag, when provided
649+
2. `language` field in `.devtrail/config.yml`, when the file exists (an explicit value — even `language: en` — is treated as a deliberate user choice)
650+
3. `$LC_ALL` / `$LANG` env vars, mapped to a supported locale (e.g., `zh_CN.UTF-8``zh-CN`, `es_MX.UTF-8``es`). Traditional Chinese (`zh_TW` / `zh_HK`) and other unsupported locales fall through.
651+
4. `en`
645652

646653
**Features:**
647654

@@ -662,6 +669,7 @@ Browse and read DevTrail documentation interactively in a terminal UI.
662669
| `Tab` | Cycle panels: Navigation → Metadata → Document |
663670
| `f` | Toggle fullscreen document |
664671
| `/` | Search |
672+
| `L` | Cycle display language (`en → es → zh-CN`) |
665673
| `Esc` | Back / Collapse / Clear search |
666674
| `?` | Help popup with all shortcuts |
667675
| `q` | Quit |
@@ -687,7 +695,7 @@ Show version, authorship, and license information.
687695
```bash
688696
$ devtrail about
689697
DevTrail CLI
690-
CLI version: cli-3.4.1
698+
CLI version: cli-3.5.0
691699
Framework version: fw-4.3.0
692700
Author: Strange Days Tech, S.A.S.
693701
License: MIT

0 commit comments

Comments
 (0)