practicode 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ai.rs CHANGED
@@ -3,13 +3,14 @@ use crate::{
3
3
  AppState, PROBLEM_NOTES_PATH, Problem, Settings, ensure_submission, normalize_ai_provider,
4
4
  render_problem,
5
5
  },
6
- process::{run_capture, sh_quote, shell_process, unique_temp_path},
6
+ process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
7
7
  };
8
8
  use anyhow::Result;
9
9
  use std::{
10
+ env,
10
11
  fs::{self, OpenOptions},
11
12
  io::Write,
12
- path::Path,
13
+ path::{Path, PathBuf},
13
14
  process::Command,
14
15
  time::Duration,
15
16
  };
@@ -39,7 +40,7 @@ pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt
39
40
 
40
41
  pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) -> String {
41
42
  if state.settings.next_source != "ai" && !force {
42
- return "AI next is disabled; using local problem bank.".to_string();
43
+ return "AI next is disabled; using local problems.".to_string();
43
44
  }
44
45
  let provider = normalize_ai_provider(&state.settings.ai_provider);
45
46
  let command = if state.settings.next_ai_command().trim().is_empty() {
@@ -78,9 +79,39 @@ pub fn default_ai_next_command(root: &Path, settings: &Settings, request: &str)
78
79
  }
79
80
  }
80
81
 
82
+ pub fn provider_status(provider: &str) -> String {
83
+ match normalize_ai_provider(provider).as_str() {
84
+ "claude" => {
85
+ if which("claude").is_some() {
86
+ "Claude CLI found.".to_string()
87
+ } else {
88
+ "Claude CLI not found. Install Claude Code or choose /provider codex.".to_string()
89
+ }
90
+ }
91
+ _ => {
92
+ if which("codex").is_none() {
93
+ return "Codex CLI not found. Install Codex CLI or choose /provider claude."
94
+ .to_string();
95
+ }
96
+ if codex_daemon_path().is_some_and(|path| path.exists()) {
97
+ "Codex CLI found. App-server daemon is available.".to_string()
98
+ } else {
99
+ "Codex CLI found. App-server daemon is not available; practicode will use codex exec directly.".to_string()
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ pub fn available_models(provider: &str) -> Vec<String> {
106
+ match normalize_ai_provider(provider).as_str() {
107
+ "codex" => codex_models(),
108
+ _ => Vec::new(),
109
+ }
110
+ }
111
+
81
112
  pub fn default_ai_next_prompt(request: &str) -> String {
82
113
  format!(
83
- "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. The app has a built-in starter problem 001-hello-world, so do not duplicate it. Create exactly one new non-duplicate coding practice problem. User request for this problem: {}. Update .practicode/problem_bank.json, the local problem files, the index, and state files. Do not include the answer in the problem statement.",
114
+ "Read AGENTS.md, docs/problem-authoring-notes.md if present, .practicode/problem_notes.md if present, problems/INDEX.md if present, .practicode/problem_bank.json if present, and .practicode/problem-state.json. Create exactly one new non-duplicate coding practice problem. The built-in 001-hello-world already exists, so do not duplicate it. User request: {}. Make the smallest valid edits: update .practicode/problem_bank.json, one problem directory, problems/INDEX.md, and .practicode/problem-state.json. Do not include the answer in the problem statement.",
84
115
  if request.is_empty() {
85
116
  "(none)"
86
117
  } else {
@@ -108,6 +139,44 @@ pub fn read_problem_notes(root: &Path) -> Result<String> {
108
139
  }
109
140
  }
110
141
 
142
+ fn codex_models() -> Vec<String> {
143
+ if which("codex").is_none() {
144
+ return Vec::new();
145
+ }
146
+ let mut command = Command::new("codex");
147
+ command.args(["app-server", "proxy"]);
148
+ let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
149
+ let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
150
+ return Vec::new();
151
+ };
152
+ if run.code != Some(0) {
153
+ return Vec::new();
154
+ }
155
+ parse_model_list(&run.stdout)
156
+ }
157
+
158
+ fn parse_model_list(output: &str) -> Vec<String> {
159
+ output
160
+ .lines()
161
+ .filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
162
+ .flat_map(|value| {
163
+ value
164
+ .pointer("/result/data")
165
+ .or_else(|| value.get("data"))
166
+ .and_then(|data| data.as_array())
167
+ .cloned()
168
+ .unwrap_or_default()
169
+ })
170
+ .filter_map(|model| {
171
+ model
172
+ .get("model")
173
+ .or_else(|| model.get("id"))
174
+ .and_then(|value| value.as_str())
175
+ .map(str::to_string)
176
+ })
177
+ .collect()
178
+ }
179
+
111
180
  fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
112
181
  let output_path = unique_temp_path("practicode-last-message", "txt");
113
182
  let mut command = Command::new("codex");
@@ -177,9 +246,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
177
246
  }
178
247
 
179
248
  fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -> String {
180
- let start = "codex app-server daemon start >/dev/null 2>&1 || true";
249
+ let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
181
250
  let mut exec = format!(
182
- "codex exec --cd {} --sandbox workspace-write",
251
+ "codex exec --ephemeral --cd {} --sandbox workspace-write",
183
252
  sh_quote(&root.display().to_string())
184
253
  );
185
254
  if let Some(model) = settings.model_arg() {
@@ -190,6 +259,14 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
190
259
  format!("{start}; {exec}")
191
260
  }
192
261
 
262
+ fn codex_daemon_path() -> Option<PathBuf> {
263
+ env::var_os("HOME").map(|home| {
264
+ PathBuf::from(home)
265
+ .join(".codex/packages/standalone/current")
266
+ .join(if cfg!(windows) { "codex.exe" } else { "codex" })
267
+ })
268
+ }
269
+
193
270
  fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {
194
271
  let mut claude = "claude --permission-mode acceptEdits".to_string();
195
272
  if let Some(model) = settings.model_arg() {
@@ -211,3 +288,15 @@ fn output_text(stdout: &str, stderr: &str) -> String {
211
288
  .collect::<Vec<_>>()
212
289
  .join("\n")
213
290
  }
291
+
292
+ #[cfg(test)]
293
+ mod tests {
294
+ use super::parse_model_list;
295
+
296
+ #[test]
297
+ fn parses_codex_model_list_response() {
298
+ let output =
299
+ r#"{"id":1,"result":{"data":[{"model":"gpt-test","displayName":"GPT Test"}]}}"#;
300
+ assert_eq!(parse_model_list(output), vec!["gpt-test"]);
301
+ }
302
+ }
package/src/core.rs CHANGED
@@ -10,7 +10,7 @@ use std::{
10
10
  };
11
11
 
12
12
  pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
13
- pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"];
13
+ pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
14
14
  pub const THEMES: &[&str] = &["dark", "light"];
15
15
  pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
16
16
  pub const BANK_PATH: &str = ".practicode/problem_bank.json";
@@ -383,20 +383,6 @@ pub fn normalize_language(language: &str) -> String {
383
383
  }
384
384
  }
385
385
 
386
- pub fn normalize_ui_language(language: &str) -> String {
387
- let lower = language.trim().to_lowercase();
388
- let short = lower
389
- .split(['-', '_'])
390
- .next()
391
- .filter(|value| !value.is_empty())
392
- .unwrap_or("en");
393
- if UI_LANGUAGES.contains(&short) {
394
- short.to_string()
395
- } else {
396
- default_ui_language()
397
- }
398
- }
399
-
400
386
  pub fn normalize_next_source(source: &str) -> String {
401
387
  if source == "ai" {
402
388
  "ai".to_string()
@@ -423,207 +409,6 @@ pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
423
409
  .unwrap_or_default()
424
410
  }
425
411
 
426
- pub fn ui_text(lang: &str, key: &str) -> &'static str {
427
- let lang = normalize_ui_language(lang);
428
- match (lang.as_str(), key) {
429
- ("ko", "problem") => "문제",
430
- ("ko", "output") => "출력",
431
- ("ko", "command") => "명령",
432
- ("ko", "commands") => "명령",
433
- ("ko", "difficulty") => "난이도",
434
- ("ko", "topics") => "주제",
435
- ("ko", "input") => "입력",
436
- ("ko", "examples") => "예시",
437
- ("ko", "example") => "예시",
438
- ("ko", "command_placeholder") => "/ 입력 후 ↑/↓로 선택, Enter 실행",
439
- ("ko", "palette_hint") => "↑/↓ 선택 | Enter 실행 | Esc 취소",
440
- ("ko", "hint_command") => "Enter 실행 | Esc 취소",
441
- ("ko", "hint_list") => "↑/↓ 이동 | Enter 열기 | Esc 닫기",
442
- ("ko", "hint_output") => "Esc 코드 | / 명령 | ? 도움말",
443
- ("ko", "hint_code") => "Esc 후 / 명령",
444
- ("ko", "hint_idle") => "/ 명령 | ? 도움말",
445
- ("ko", "help_title") => "도움말",
446
- ("ko", "daily_loop") => "기본 흐름",
447
- ("ko", "keys") => "키",
448
- ("ko", "debug_prints") => "디버그 출력",
449
- ("ko", "cmd_run") => "현재 제출을 채점",
450
- ("ko", "cmd_edit") => "코드 편집기로 돌아가기",
451
- ("ko", "cmd_next") => "다음 문제 열기",
452
- ("ko", "cmd_prev") => "이전 문제 열기",
453
- ("ko", "cmd_list") => "문제 목록 열기",
454
- ("ko", "cmd_open") => "번호, id, slug로 문제 열기",
455
- ("ko", "cmd_giveup") => "정답 보기",
456
- ("ko", "cmd_ai") => "현재 문제와 코드에 대해 AI에게 질문",
457
- ("ko", "cmd_provider") => "AI provider 설정",
458
- ("ko", "cmd_model") => "AI model 설정",
459
- ("ko", "cmd_note") => "다음 문제 생성 메모 추가",
460
- ("ko", "cmd_notes") => "저장된 메모 보기",
461
- ("ko", "cmd_lang") => "코드 언어 설정",
462
- ("ko", "cmd_ui") => "UI 언어 설정",
463
- ("ko", "cmd_theme") => "테마 설정",
464
- ("ko", "cmd_source") => "다음 문제 출처 설정",
465
- ("ko", "cmd_exit") => "종료",
466
- ("ko", "cmd_help") => "도움말 열기",
467
-
468
- ("ja", "problem") => "問題",
469
- ("ja", "output") => "出力",
470
- ("ja", "command") => "コマンド",
471
- ("ja", "commands") => "コマンド",
472
- ("ja", "difficulty") => "難易度",
473
- ("ja", "topics") => "トピック",
474
- ("ja", "input") => "入力",
475
- ("ja", "examples") => "例",
476
- ("ja", "example") => "例",
477
- ("ja", "command_placeholder") => "/ を入力し ↑/↓ で選択、Enter で実行",
478
- ("ja", "palette_hint") => "↑/↓ 選択 | Enter 実行 | Esc キャンセル",
479
- ("ja", "hint_command") => "Enter 実行 | Esc キャンセル",
480
- ("ja", "hint_list") => "↑/↓ 移動 | Enter 開く | Esc 閉じる",
481
- ("ja", "hint_output") => "Esc コード | / コマンド | ? ヘルプ",
482
- ("ja", "hint_code") => "Esc の後 / コマンド",
483
- ("ja", "hint_idle") => "/ コマンド | ? ヘルプ",
484
- ("ja", "help_title") => "ヘルプ",
485
- ("ja", "daily_loop") => "基本フロー",
486
- ("ja", "keys") => "キー",
487
- ("ja", "debug_prints") => "デバッグ出力",
488
- ("ja", "cmd_run") => "現在の提出を判定",
489
- ("ja", "cmd_edit") => "コードエディタに戻る",
490
- ("ja", "cmd_next") => "次の問題を開く",
491
- ("ja", "cmd_prev") => "前の問題を開く",
492
- ("ja", "cmd_list") => "問題一覧を開く",
493
- ("ja", "cmd_open") => "番号、id、slug で問題を開く",
494
- ("ja", "cmd_giveup") => "解答を見る",
495
- ("ja", "cmd_ai") => "現在の問題とコードについて AI に質問",
496
- ("ja", "cmd_provider") => "AI provider を設定",
497
- ("ja", "cmd_model") => "AI model を設定",
498
- ("ja", "cmd_note") => "次の問題生成メモを追加",
499
- ("ja", "cmd_notes") => "保存済みメモを見る",
500
- ("ja", "cmd_lang") => "コード言語を設定",
501
- ("ja", "cmd_ui") => "UI 言語を設定",
502
- ("ja", "cmd_theme") => "テーマを設定",
503
- ("ja", "cmd_source") => "次の問題の取得元を設定",
504
- ("ja", "cmd_exit") => "終了",
505
- ("ja", "cmd_help") => "ヘルプを開く",
506
-
507
- ("zh", "problem") => "题目",
508
- ("zh", "output") => "输出",
509
- ("zh", "command") => "命令",
510
- ("zh", "commands") => "命令",
511
- ("zh", "difficulty") => "难度",
512
- ("zh", "topics") => "主题",
513
- ("zh", "input") => "输入",
514
- ("zh", "examples") => "示例",
515
- ("zh", "example") => "示例",
516
- ("zh", "command_placeholder") => "输入 / 后用 ↑/↓ 选择,Enter 执行",
517
- ("zh", "palette_hint") => "↑/↓ 选择 | Enter 执行 | Esc 取消",
518
- ("zh", "hint_command") => "Enter 执行 | Esc 取消",
519
- ("zh", "hint_list") => "↑/↓ 移动 | Enter 打开 | Esc 关闭",
520
- ("zh", "hint_output") => "Esc 代码 | / 命令 | ? 帮助",
521
- ("zh", "hint_code") => "Esc 后输入 / 命令",
522
- ("zh", "hint_idle") => "/ 命令 | ? 帮助",
523
- ("zh", "help_title") => "帮助",
524
- ("zh", "daily_loop") => "日常流程",
525
- ("zh", "keys") => "按键",
526
- ("zh", "debug_prints") => "调试输出",
527
- ("zh", "cmd_run") => "评测当前提交",
528
- ("zh", "cmd_edit") => "回到代码编辑器",
529
- ("zh", "cmd_next") => "打开下一题",
530
- ("zh", "cmd_prev") => "打开上一题",
531
- ("zh", "cmd_list") => "打开题目列表",
532
- ("zh", "cmd_open") => "按编号、id 或 slug 打开题目",
533
- ("zh", "cmd_giveup") => "显示参考答案",
534
- ("zh", "cmd_ai") => "向 AI 询问当前题目和代码",
535
- ("zh", "cmd_provider") => "设置 AI provider",
536
- ("zh", "cmd_model") => "设置 AI model",
537
- ("zh", "cmd_note") => "添加下次出题备注",
538
- ("zh", "cmd_notes") => "查看保存的备注",
539
- ("zh", "cmd_lang") => "设置代码语言",
540
- ("zh", "cmd_ui") => "设置 UI 语言",
541
- ("zh", "cmd_theme") => "设置主题",
542
- ("zh", "cmd_source") => "设置下一题来源",
543
- ("zh", "cmd_exit") => "退出",
544
- ("zh", "cmd_help") => "打开帮助",
545
-
546
- ("es", "problem") => "Problema",
547
- ("es", "output") => "Salida",
548
- ("es", "command") => "Comando",
549
- ("es", "commands") => "Comandos",
550
- ("es", "difficulty") => "Dificultad",
551
- ("es", "topics") => "Temas",
552
- ("es", "input") => "Entrada",
553
- ("es", "examples") => "Ejemplos",
554
- ("es", "example") => "Ejemplo",
555
- ("es", "command_placeholder") => "Escribe /, elige con ↑/↓, Enter ejecuta",
556
- ("es", "palette_hint") => "↑/↓ elegir | Enter ejecutar | Esc cancelar",
557
- ("es", "hint_command") => "Enter ejecutar | Esc cancelar",
558
- ("es", "hint_list") => "↑/↓ mover | Enter abrir | Esc cerrar",
559
- ("es", "hint_output") => "Esc codigo | / comando | ? ayuda",
560
- ("es", "hint_code") => "Esc y luego / comando",
561
- ("es", "hint_idle") => "/ comando | ? ayuda",
562
- ("es", "help_title") => "Ayuda",
563
- ("es", "daily_loop") => "Flujo diario",
564
- ("es", "keys") => "Teclas",
565
- ("es", "debug_prints") => "Salida de depuracion",
566
- ("es", "cmd_run") => "Evalua la solucion actual",
567
- ("es", "cmd_edit") => "Volver al editor de codigo",
568
- ("es", "cmd_next") => "Abrir el siguiente problema",
569
- ("es", "cmd_prev") => "Abrir el problema anterior",
570
- ("es", "cmd_list") => "Abrir la lista de problemas",
571
- ("es", "cmd_open") => "Abrir por numero, id o slug",
572
- ("es", "cmd_giveup") => "Mostrar la respuesta de referencia",
573
- ("es", "cmd_ai") => "Preguntar a AI sobre el problema y codigo actuales",
574
- ("es", "cmd_provider") => "Configurar AI provider",
575
- ("es", "cmd_model") => "Configurar AI model",
576
- ("es", "cmd_note") => "Agregar nota para generar problemas",
577
- ("es", "cmd_notes") => "Ver notas guardadas",
578
- ("es", "cmd_lang") => "Configurar lenguaje de codigo",
579
- ("es", "cmd_ui") => "Configurar idioma de UI",
580
- ("es", "cmd_theme") => "Configurar tema",
581
- ("es", "cmd_source") => "Configurar fuente del siguiente problema",
582
- ("es", "cmd_exit") => "Salir",
583
- ("es", "cmd_help") => "Abrir ayuda",
584
-
585
- (_, "problem") => "Problem",
586
- (_, "output") => "Output",
587
- (_, "command") => "Command",
588
- (_, "commands") => "Commands",
589
- (_, "difficulty") => "Difficulty",
590
- (_, "topics") => "Topics",
591
- (_, "input") => "Input",
592
- (_, "examples") => "Examples",
593
- (_, "example") => "Example",
594
- (_, "command_placeholder") => "Type /, move with ↑/↓, Enter runs",
595
- (_, "palette_hint") => "↑/↓ select | Enter run | Esc cancel",
596
- (_, "hint_command") => "Enter submit | Esc cancel",
597
- (_, "hint_list") => "up/down move | Enter open | Esc close",
598
- (_, "hint_output") => "Esc code | / command | ? help",
599
- (_, "hint_code") => "Esc then / command",
600
- (_, "hint_idle") => "/ command | ? help",
601
- (_, "help_title") => "Help",
602
- (_, "daily_loop") => "Daily loop",
603
- (_, "keys") => "Keys",
604
- (_, "debug_prints") => "Debug prints",
605
- (_, "cmd_run") => "Judge the current submission",
606
- (_, "cmd_edit") => "Return to the code editor",
607
- (_, "cmd_next") => "Open the next problem",
608
- (_, "cmd_prev") => "Open the previous problem",
609
- (_, "cmd_list") => "Browse problems",
610
- (_, "cmd_open") => "Open by number, id, or slug",
611
- (_, "cmd_giveup") => "Show the reference answer",
612
- (_, "cmd_ai") => "Ask AI about the current problem and code",
613
- (_, "cmd_provider") => "Set AI provider",
614
- (_, "cmd_model") => "Set AI model",
615
- (_, "cmd_note") => "Add a next-problem note",
616
- (_, "cmd_notes") => "Show saved notes",
617
- (_, "cmd_lang") => "Set code language",
618
- (_, "cmd_ui") => "Set UI language",
619
- (_, "cmd_theme") => "Set theme",
620
- (_, "cmd_source") => "Set next-problem source",
621
- (_, "cmd_exit") => "Quit",
622
- (_, "cmd_help") => "Open help",
623
- _ => "",
624
- }
625
- }
626
-
627
412
  pub fn template_for(language: &str) -> String {
628
413
  match normalize_language(language).as_str() {
629
414
  "python" => "# Read from stdin and print to stdout.\nimport sys\n\n\n".to_string(),
@@ -690,6 +475,66 @@ pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
690
475
  )
691
476
  }
692
477
 
478
+ pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
479
+ let lang = normalize_ui_language(ui_language);
480
+ let number = problem
481
+ .id
482
+ .split_once('-')
483
+ .map(|(number, _)| number)
484
+ .unwrap_or(&problem.id);
485
+ let mut lines = vec![
486
+ format!("{number}. {}", localized(&problem.title, &lang)),
487
+ format!(
488
+ "{}: {} {}: {}",
489
+ ui_text(&lang, "difficulty"),
490
+ problem.difficulty,
491
+ ui_text(&lang, "topics"),
492
+ problem.topics.join(", ")
493
+ ),
494
+ String::new(),
495
+ localized(&problem.statement, &lang),
496
+ ];
497
+ push_tui_section(
498
+ &mut lines,
499
+ ui_text(&lang, "input"),
500
+ &localized(&problem.input, &lang),
501
+ );
502
+ push_tui_section(
503
+ &mut lines,
504
+ ui_text(&lang, "output"),
505
+ &localized(&problem.output, &lang),
506
+ );
507
+ lines.push(String::new());
508
+ lines.push(ui_text(&lang, "examples").to_string());
509
+ for (index, case) in problem.examples.iter().enumerate() {
510
+ lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
511
+ lines.push(format!(" {}:", ui_text(&lang, "input")));
512
+ push_case_text(&mut lines, &case.input);
513
+ lines.push(format!(" {}:", ui_text(&lang, "output")));
514
+ push_case_text(&mut lines, &case.output);
515
+ }
516
+ lines.join("\n").trim_end().to_string()
517
+ }
518
+
519
+ fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
520
+ lines.push(String::new());
521
+ lines.push(title.to_string());
522
+ for line in body.trim_end().lines() {
523
+ lines.push(format!(" {line}"));
524
+ }
525
+ }
526
+
527
+ fn push_case_text(lines: &mut Vec<String>, body: &str) {
528
+ let body = body.trim_end();
529
+ if body.is_empty() {
530
+ lines.push(" <empty>".to_string());
531
+ } else {
532
+ for line in body.lines() {
533
+ lines.push(format!(" {line}"));
534
+ }
535
+ }
536
+ }
537
+
693
538
  pub fn fenced_text(value: &str) -> String {
694
539
  let mut body = value.to_string();
695
540
  if !body.ends_with('\n') {
package/src/i18n.rs ADDED
@@ -0,0 +1,45 @@
1
+ use std::{collections::HashMap, sync::OnceLock};
2
+
3
+ pub const UI_LANGUAGES: &[&str] = &["en", "ko", "ja", "zh", "es"];
4
+
5
+ static EN: OnceLock<HashMap<String, String>> = OnceLock::new();
6
+ static KO: OnceLock<HashMap<String, String>> = OnceLock::new();
7
+ static JA: OnceLock<HashMap<String, String>> = OnceLock::new();
8
+ static ZH: OnceLock<HashMap<String, String>> = OnceLock::new();
9
+ static ES: OnceLock<HashMap<String, String>> = OnceLock::new();
10
+
11
+ pub fn normalize_ui_language(language: &str) -> String {
12
+ let lower = language.trim().to_lowercase();
13
+ let short = lower
14
+ .split(['-', '_'])
15
+ .next()
16
+ .filter(|value| !value.is_empty())
17
+ .unwrap_or("en");
18
+ if UI_LANGUAGES.contains(&short) {
19
+ short.to_string()
20
+ } else {
21
+ "en".to_string()
22
+ }
23
+ }
24
+
25
+ pub fn ui_text(lang: &str, key: &str) -> &'static str {
26
+ catalog(&normalize_ui_language(lang))
27
+ .get(key)
28
+ .or_else(|| catalog("en").get(key))
29
+ .map(String::as_str)
30
+ .unwrap_or("")
31
+ }
32
+
33
+ fn catalog(lang: &str) -> &'static HashMap<String, String> {
34
+ match lang {
35
+ "ko" => KO.get_or_init(|| load(include_str!("../assets/i18n/ko.json"))),
36
+ "ja" => JA.get_or_init(|| load(include_str!("../assets/i18n/ja.json"))),
37
+ "zh" => ZH.get_or_init(|| load(include_str!("../assets/i18n/zh.json"))),
38
+ "es" => ES.get_or_init(|| load(include_str!("../assets/i18n/es.json"))),
39
+ _ => EN.get_or_init(|| load(include_str!("../assets/i18n/en.json"))),
40
+ }
41
+ }
42
+
43
+ fn load(text: &str) -> HashMap<String, String> {
44
+ serde_json::from_str(text).expect("valid i18n catalog")
45
+ }
package/src/lib.rs CHANGED
@@ -4,9 +4,11 @@ use std::{env, io::stdout};
4
4
 
5
5
  pub mod ai;
6
6
  pub mod core;
7
+ pub mod i18n;
7
8
  pub mod process;
8
9
  pub mod text;
9
10
  pub mod tui;
11
+ pub mod update;
10
12
 
11
13
  pub fn run_cli() -> Result<()> {
12
14
  let root = env::current_dir().context("read current directory")?;