Skip to content

Commit 1187e53

Browse files
montfortclaude
andauthored
feat(cli): language-aware devtrail explore (cli-3.4.0) (#57)
Wire `language` config and a new `--lang` flag through the explore TUI so framework governance docs (QUICK-REFERENCE, AGENT-RULES, China regulatory guides, etc.) are served from `i18n/<lang>/` when a translation exists, falling back silently to English otherwise. Adopter content under user subgroups stays untouched. A shared utils::resolve_localized_path helper now backs both `devtrail explore` and the existing `devtrail new` template lookup, so they share one definition of how `i18n/<lang>/<filename>` resolves. Resolution order for explore: --lang > config.language > "en". Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 54c668c commit 1187e53

17 files changed

Lines changed: 325 additions & 56 deletions

File tree

CHANGELOG.md

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

88
---
99

10+
## CLI 3.4.0 — Language-Aware `devtrail explore`
11+
12+
### Added (CLI)
13+
- `devtrail explore` now resolves framework governance docs (`QUICK-REFERENCE`, `AGENT-RULES`, `CHINA-REGULATORY-FRAMEWORK`, `PIPL-PIPIA-GUIDE`, etc.) in the language set by `language` in `.devtrail/config.yml`. With `language: zh-CN` or `es`, the navigation tree, titles, and document body all switch to the translated variant — the English original is used silently as a fallback when no translation exists. CJK rendering relies on the Unicode-safe layout work done in 3.2.3 / 3.2.4.
14+
- New `--lang <code>` flag on `devtrail explore` to override the configured language for a single session (e.g., `devtrail explore --lang zh-CN`). Resolution order: `--lang` > `config.language` > `en`.
15+
- Adopter-authored content under subgroups (`02-design/decisions/`, `05-operations/incidents/`, etc.) is intentionally never localized — it has no canonical i18n sibling.
16+
17+
### Changed (CLI)
18+
- Shared `utils::resolve_localized_path()` is now the single source of truth for `i18n/<lang>/<filename>` lookups. `devtrail new` (templates) and `devtrail explore` (governance docs) both delegate to it.
19+
20+
---
21+
1022
## Framework 4.3.0 / CLI 3.3.0 — China Regulatory Coverage (TC260, PIPL, GB 45438, CAC, GB/T 45652, CSL)
1123

1224
DevTrail now supports six Chinese AI / data regulations as an opt-in regional scope. Existing projects are unaffected — Chinese frameworks activate only when `regional_scope: china` is added to `.devtrail/config.yml`.

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.3.0` | The `devtrail` binary |
210+
| CLI | `cli-` | `cli-3.4.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.3.0"
3+
version = "3.4.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: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
use anyhow::{bail, Result};
22

3+
use crate::config::DevTrailConfig;
34
use crate::utils;
45

5-
pub fn run(path: &str) -> Result<()> {
6+
pub fn run(path: &str, lang_override: Option<&str>) -> Result<()> {
67
let resolved = match utils::resolve_project_root(path) {
78
Some(r) => r,
89
None => {
@@ -12,5 +13,13 @@ pub fn run(path: &str) -> Result<()> {
1213
}
1314
};
1415

15-
crate::tui::run(&resolved.path, resolved.is_fallback)
16+
// Effective language: --lang flag > config.language > "en".
17+
let language = match lang_override {
18+
Some(l) => l.to_string(),
19+
None => DevTrailConfig::load(&resolved.path)
20+
.map(|c| c.language)
21+
.unwrap_or_else(|_| "en".to_string()),
22+
};
23+
24+
crate::tui::run(&resolved.path, resolved.is_fallback, &language)
1625
}

cli/src/commands/new.rs

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -204,15 +204,8 @@ fn resolve_template_path(
204204
lang: &str,
205205
) -> PathBuf {
206206
let template_name = format!("TEMPLATE-{}.md", doc_type.prefix());
207-
if lang != "en" {
208-
let i18n_path = devtrail_dir
209-
.join(format!("templates/i18n/{}", lang))
210-
.join(&template_name);
211-
if i18n_path.exists() {
212-
return i18n_path;
213-
}
214-
}
215-
devtrail_dir.join("templates").join(&template_name)
207+
let templates_dir = devtrail_dir.join("templates");
208+
utils::resolve_localized_path(&templates_dir, &template_name, lang)
216209
}
217210

218211
#[cfg(test)]

cli/src/main.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,10 @@ enum Commands {
168168
/// Target directory (default: current directory)
169169
#[arg(default_value = ".")]
170170
path: String,
171+
/// Display language override (e.g., en, es, zh-CN). Defaults to
172+
/// `language` from .devtrail/config.yml.
173+
#[arg(long)]
174+
lang: Option<String>,
171175
},
172176
}
173177

@@ -219,7 +223,7 @@ fn main() {
219223
top,
220224
} => commands::analyze::run(&path, threshold, &output, top),
221225
#[cfg(feature = "tui")]
222-
Commands::Explore { path } => commands::explore::run(&path),
226+
Commands::Explore { path, lang } => commands::explore::run(&path, lang.as_deref()),
223227
};
224228

225229
if let Err(e) = result {

cli/src/tui/app.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,12 +96,15 @@ pub struct App {
9696
pub project_root: PathBuf,
9797
/// Whether we're using a fallback path (repo root instead of cwd)
9898
pub is_fallback: bool,
99+
/// Active display language ("en", "es", "zh-CN"). Persisted on the app
100+
/// so refresh ('r') rebuilds the index in the same locale.
101+
pub language: String,
99102
}
100103

101104
impl App {
102-
pub fn new(project_root: &Path, is_fallback: bool) -> Self {
105+
pub fn new(project_root: &Path, is_fallback: bool, language: &str) -> Self {
103106
let devtrail_dir = project_root.join(".devtrail");
104-
let index = DocIndex::build(&devtrail_dir);
107+
let index = DocIndex::build(&devtrail_dir, language);
105108
let num_groups = index.groups.len();
106109

107110
Self {
@@ -125,6 +128,7 @@ impl App {
125128
should_quit: false,
126129
project_root: project_root.to_path_buf(),
127130
is_fallback,
131+
language: language.to_string(),
128132
}
129133
}
130134

cli/src/tui/event.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,8 @@ fn handle_key(app: &mut App, key: KeyEvent) {
130130
KeyCode::Char('r') => {
131131
let root = app.project_root.clone();
132132
let fallback = app.is_fallback;
133-
*app = App::new(&root, fallback);
133+
let language = app.language.clone();
134+
*app = App::new(&root, fallback, &language);
134135
}
135136

136137
_ => {}

cli/src/tui/index.rs

Lines changed: 177 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::HashMap;
22
use std::path::{Path, PathBuf};
33

44
use super::document::DocFrontMatter;
5+
use crate::utils;
56

67
/// A group in the documentation hierarchy (e.g., "02-design")
78
#[derive(Debug, Clone)]
@@ -109,8 +110,15 @@ const GROUP_DEFS: &[(&str, &str, &[SubgroupDef])] = &[
109110
];
110111

111112
impl DocIndex {
112-
/// Build the index by scanning the .devtrail directory
113-
pub fn build(devtrail_dir: &Path) -> Self {
113+
/// Build the index by scanning the .devtrail directory.
114+
///
115+
/// `language` selects the preferred locale (e.g. `"en"`, `"es"`, `"zh-CN"`).
116+
/// When non-`"en"`, framework files at group roots (e.g. governance docs)
117+
/// are transparently swapped for their `i18n/<lang>/<filename>` counterpart
118+
/// when one exists; otherwise the English original is used. User-authored
119+
/// content under subgroups (`decisions/`, `incidents/`, ...) is never
120+
/// localized.
121+
pub fn build(devtrail_dir: &Path, language: &str) -> Self {
114122
let mut groups = Vec::new();
115123
let mut relations = RelationIndex::default();
116124
let mut total_docs = 0;
@@ -128,17 +136,20 @@ impl DocIndex {
128136
continue;
129137
}
130138

131-
// Scan files directly in the group dir (non-recursive, subgroups scanned separately)
132-
let files = scan_md_files_flat(&group_path, &mut relations);
139+
// Scan files directly in the group dir. Group roots host framework
140+
// docs that ship with translations under `i18n/<lang>/`, so apply
141+
// localization here.
142+
let files = scan_md_files_flat(&group_path, Some(language), &mut relations);
133143
total_docs += files.len();
134144

135145
// Scan subgroups and their user-created subdirectories
136146
let mut subgroups = Vec::new();
137147
for &(sg_name, sg_label) in subgroup_defs {
138148
let sg_path = group_path.join(sg_name);
139149
if sg_path.exists() {
140-
// Files directly in the subgroup
141-
let sg_files = scan_md_files_flat(&sg_path, &mut relations);
150+
// Subgroups hold adopter content, which has no localized
151+
// sibling to swap to.
152+
let sg_files = scan_md_files_flat(&sg_path, None, &mut relations);
142153
total_docs += sg_files.len();
143154

144155
// Scan user-created subdirectories
@@ -279,8 +290,18 @@ fn entry_matches(filename: &str, path: &Path, ref_filename: &str, clean_ref: &st
279290
false
280291
}
281292

282-
/// Scan only direct .md files in a directory (non-recursive, for group root dirs)
283-
fn scan_md_files_flat(dir: &Path, relations: &mut RelationIndex) -> Vec<DocEntry> {
293+
/// Scan only direct .md files in a directory (non-recursive, for group root dirs).
294+
///
295+
/// When `localize` is `Some(lang)` and a translation exists at
296+
/// `dir/i18n/<lang>/<filename>`, the entry's path (and therefore the title /
297+
/// frontmatter shown in the TUI) is taken from the translated file. Pass
298+
/// `None` for directories whose contents are user-authored and have no
299+
/// localized counterpart.
300+
fn scan_md_files_flat(
301+
dir: &Path,
302+
localize: Option<&str>,
303+
relations: &mut RelationIndex,
304+
) -> Vec<DocEntry> {
284305
let mut entries = Vec::new();
285306

286307
let Ok(read_dir) = std::fs::read_dir(dir) else {
@@ -314,11 +335,19 @@ fn scan_md_files_flat(dir: &Path, relations: &mut RelationIndex) -> Vec<DocEntry
314335
.unwrap_or("")
315336
.to_string();
316337

317-
let meta = quick_scan_frontmatter(&path, relations);
338+
// Prefer a localized variant when this directory is a framework
339+
// root that ships with translations. Falls back to the English
340+
// original silently when no translation exists.
341+
let resolved_path = match localize {
342+
Some(lang) => utils::resolve_localized_path(dir, &filename, lang),
343+
None => path,
344+
};
345+
346+
let meta = quick_scan_frontmatter(&resolved_path, relations);
318347

319348
entries.push(DocEntry {
320349
filename,
321-
path,
350+
path: resolved_path,
322351
title: meta.title,
323352
id: meta.id,
324353
doc_type: meta.doc_type,
@@ -540,3 +569,141 @@ fn quick_scan_frontmatter(path: &Path, relations: &mut RelationIndex) -> Scanned
540569
None => fallback_meta(path, Some(body)),
541570
}
542571
}
572+
573+
#[cfg(test)]
574+
mod tests {
575+
use super::*;
576+
577+
/// Build a fixture .devtrail tree with a translated and an English-only
578+
/// governance doc, and verify DocIndex prefers the translation when
579+
/// language=zh-CN, falling back to English silently when no translation
580+
/// exists.
581+
#[test]
582+
fn build_zh_cn_swaps_governance_path_when_translation_present() {
583+
let tmp = tempfile::TempDir::new().unwrap();
584+
let devtrail_dir = tmp.path().join(".devtrail");
585+
let governance = devtrail_dir.join("00-governance");
586+
let zh = governance.join("i18n").join("zh-CN");
587+
std::fs::create_dir_all(&zh).unwrap();
588+
589+
// Translated doc (governance).
590+
std::fs::write(
591+
governance.join("QUICK-REFERENCE.md"),
592+
"# Quick Reference\n\nEnglish body.\n",
593+
)
594+
.unwrap();
595+
std::fs::write(
596+
zh.join("QUICK-REFERENCE.md"),
597+
"# 快速参考\n\n中文正文。\n",
598+
)
599+
.unwrap();
600+
601+
// English-only doc (no zh-CN sibling).
602+
std::fs::write(
603+
governance.join("ISO-25010-2023-REFERENCE.md"),
604+
"# ISO 25010\n\nEnglish only.\n",
605+
)
606+
.unwrap();
607+
608+
let index = DocIndex::build(&devtrail_dir, "zh-CN");
609+
610+
let governance_group = index
611+
.groups
612+
.iter()
613+
.find(|g| g.name == "00-governance")
614+
.expect("governance group present");
615+
616+
let quick_ref = governance_group
617+
.files
618+
.iter()
619+
.find(|e| e.filename == "QUICK-REFERENCE.md")
620+
.expect("QUICK-REFERENCE indexed");
621+
assert_eq!(
622+
quick_ref.path,
623+
zh.join("QUICK-REFERENCE.md"),
624+
"zh-CN translation should be preferred"
625+
);
626+
assert_eq!(quick_ref.title, "快速参考");
627+
628+
let iso = governance_group
629+
.files
630+
.iter()
631+
.find(|e| e.filename == "ISO-25010-2023-REFERENCE.md")
632+
.expect("ISO doc indexed");
633+
assert_eq!(
634+
iso.path,
635+
governance.join("ISO-25010-2023-REFERENCE.md"),
636+
"missing translation must fall back to English silently"
637+
);
638+
assert_eq!(iso.title, "ISO 25010");
639+
}
640+
641+
#[test]
642+
fn build_en_never_descends_into_i18n_subdirs() {
643+
let tmp = tempfile::TempDir::new().unwrap();
644+
let devtrail_dir = tmp.path().join(".devtrail");
645+
let governance = devtrail_dir.join("00-governance");
646+
let zh = governance.join("i18n").join("zh-CN");
647+
std::fs::create_dir_all(&zh).unwrap();
648+
649+
std::fs::write(governance.join("AGENT-RULES.md"), "# Rules").unwrap();
650+
std::fs::write(zh.join("AGENT-RULES.md"), "# 规则").unwrap();
651+
652+
let index = DocIndex::build(&devtrail_dir, "en");
653+
let governance_group = index
654+
.groups
655+
.iter()
656+
.find(|g| g.name == "00-governance")
657+
.unwrap();
658+
659+
// Exactly one entry: the English root file. The translation must
660+
// never appear as a separate doc (no duplication of total_docs).
661+
assert_eq!(governance_group.files.len(), 1);
662+
assert_eq!(
663+
governance_group.files[0].path,
664+
governance.join("AGENT-RULES.md")
665+
);
666+
}
667+
668+
#[test]
669+
fn build_does_not_localize_user_subgroups() {
670+
let tmp = tempfile::TempDir::new().unwrap();
671+
let devtrail_dir = tmp.path().join(".devtrail");
672+
let decisions = devtrail_dir.join("02-design").join("decisions");
673+
let stray_zh = decisions.join("i18n").join("zh-CN");
674+
std::fs::create_dir_all(&stray_zh).unwrap();
675+
676+
std::fs::write(
677+
decisions.join("ADR-2026-01-01-001-foo.md"),
678+
"# English ADR",
679+
)
680+
.unwrap();
681+
// A translation file under a user subgroup must be ignored — adopter
682+
// content has no canonical English<->zh mapping.
683+
std::fs::write(stray_zh.join("ADR-2026-01-01-001-foo.md"), "# 中文 ADR")
684+
.unwrap();
685+
686+
let index = DocIndex::build(&devtrail_dir, "zh-CN");
687+
let design = index
688+
.groups
689+
.iter()
690+
.find(|g| g.name == "02-design")
691+
.unwrap();
692+
let decisions_sg = design
693+
.subgroups
694+
.iter()
695+
.find(|s| s.name == "decisions")
696+
.unwrap();
697+
698+
let adr = decisions_sg
699+
.files
700+
.iter()
701+
.find(|e| e.filename == "ADR-2026-01-01-001-foo.md")
702+
.expect("ADR indexed");
703+
assert_eq!(
704+
adr.path,
705+
decisions.join("ADR-2026-01-01-001-foo.md"),
706+
"user-authored docs must not be swapped for any i18n sibling"
707+
);
708+
}
709+
}

0 commit comments

Comments
 (0)