practicode 0.1.9 → 0.1.11

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/Cargo.lock CHANGED
@@ -453,7 +453,7 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
453
453
 
454
454
  [[package]]
455
455
  name = "practicode"
456
- version = "0.1.9"
456
+ version = "0.1.11"
457
457
  dependencies = [
458
458
  "anyhow",
459
459
  "crossterm",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "practicode"
3
- version = "0.1.9"
3
+ version = "0.1.11"
4
4
  edition = "2024"
5
5
  description = "Local-first coding-test practice in a Rust terminal UI with optional AI help."
6
6
  readme = "README.md"
package/README.md CHANGED
@@ -111,6 +111,9 @@ Submissions are saved as you type under `submissions/<problem-id>/solution.<ext>
111
111
  | `/generate-ui ko, en` | Limit generated problem text languages, or use `all` |
112
112
  | `/provider codex` | Set AI provider and show local CLI/daemon status |
113
113
  | `/model auto` | Use the provider default model for `/hint` and AI-backed `/next` |
114
+ | `/effort auto` | Use the provider default effort, or set `low`, `medium`, `high`, `xhigh`; Claude also supports `max` |
115
+ | `/note` | Edit problem-generation notes used by AI-backed `/next` and `/generate` |
116
+ | `/notes` | Show saved problem-generation notes |
114
117
  | `/language python` | Set code language: `python`, `ts`, `java`, `rust` |
115
118
  | `/ui en` | Set UI language: `en`, `ko`, `ja`, `zh`, `es` |
116
119
  | `/theme dark` | Set theme: `dark` or `light` |
@@ -121,9 +124,9 @@ Older command names such as `/prev`, `/list`, `/giveup`, and `/lang` still work
121
124
 
122
125
  The default UI language is English. Switch it any time with `/ui ko`, `/ui ja`, `/ui zh`, or `/ui es`.
123
126
 
124
- Your user profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, topics to avoid, and generation language scope. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
127
+ Your user profile is saved in `.practicode/problem-state.json`. It keeps UI language, code language, theme, preferred difficulty, preferred topics, topics to avoid, generation language scope, and AI provider/model/effort. `auto` difficulty follows gradual progression; a fixed difficulty asks local selection and AI generation to prefer that level.
125
128
 
126
- Inside `/profile`, use `up/down` to move and `Space` or `Enter` to cycle common settings or enable/disable generated answer/UI languages. Use slash commands for free-form lists such as `/topics arrays, strings`.
129
+ Inside `/profile`, use `up/down` to move and `Space` or `Enter` to cycle common settings, AI provider/model/effort, or generated answer/UI languages. The notes row opens an editor for `.practicode/problem_notes.md`. Use slash commands for free-form lists such as `/topics arrays, strings`.
127
130
 
128
131
  ## Problem Flow
129
132
 
@@ -144,6 +147,7 @@ Codex is the default provider:
144
147
  ```text
145
148
  /provider codex
146
149
  /model auto
150
+ /effort auto
147
151
  ```
148
152
 
149
153
  Claude Code is also supported:
@@ -151,6 +155,7 @@ Claude Code is also supported:
151
155
  ```text
152
156
  /provider claude
153
157
  /model sonnet
158
+ /effort high
154
159
  ```
155
160
 
156
161
  Generated problems and submissions stay local:
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "Use provider default model",
48
48
  "cmd_model_available": "Use an available provider model",
49
49
  "cmd_model_custom": "Type a model name",
50
- "cmd_note": "Add a next-problem note",
50
+ "cmd_effort": "Set AI effort",
51
+ "cmd_effort_auto": "Use provider default effort",
52
+ "cmd_effort_max": "Use Claude max effort",
53
+ "cmd_note": "Edit problem-generation notes",
51
54
  "cmd_notes": "Show saved notes",
52
55
  "cmd_lang": "Set code language",
53
56
  "cmd_ui": "Set UI language",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "Usar el modelo predeterminado del provider",
48
48
  "cmd_model_available": "Usar un modelo disponible del provider",
49
49
  "cmd_model_custom": "Escribir un nombre de modelo",
50
- "cmd_note": "Agregar nota para generar problemas",
50
+ "cmd_effort": "Configurar AI effort",
51
+ "cmd_effort_auto": "Usar el effort predeterminado del provider",
52
+ "cmd_effort_max": "Usar Claude max effort",
53
+ "cmd_note": "Editar notas para generar problemas",
51
54
  "cmd_notes": "Ver notas guardadas",
52
55
  "cmd_lang": "Configurar lenguaje de codigo",
53
56
  "cmd_ui": "Configurar idioma de UI",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "provider の既定モデルを使用",
48
48
  "cmd_model_available": "利用可能な provider モデルを使用",
49
49
  "cmd_model_custom": "モデル名を直接入力",
50
- "cmd_note": "次の問題生成メモを追加",
50
+ "cmd_effort": "AI effort を設定",
51
+ "cmd_effort_auto": "provider の既定 effort を使用",
52
+ "cmd_effort_max": "Claude max effort を使用",
53
+ "cmd_note": "問題生成メモを編集",
51
54
  "cmd_notes": "保存済みメモを見る",
52
55
  "cmd_lang": "コード言語を設定",
53
56
  "cmd_ui": "UI 言語を設定",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "provider 기본 모델 사용",
48
48
  "cmd_model_available": "사용 가능한 provider 모델 선택",
49
49
  "cmd_model_custom": "모델 이름 직접 입력",
50
- "cmd_note": "다음 문제 생성 메모 추가",
50
+ "cmd_effort": "AI effort 설정",
51
+ "cmd_effort_auto": "provider 기본 effort 사용",
52
+ "cmd_effort_max": "Claude max effort 사용",
53
+ "cmd_note": "문제 생성 메모 편집",
51
54
  "cmd_notes": "저장된 메모 보기",
52
55
  "cmd_lang": "코드 언어 설정",
53
56
  "cmd_ui": "UI 언어 설정",
@@ -47,7 +47,10 @@
47
47
  "cmd_model_auto": "使用 provider 默认模型",
48
48
  "cmd_model_available": "使用可用的 provider 模型",
49
49
  "cmd_model_custom": "输入模型名称",
50
- "cmd_note": "添加下次出题备注",
50
+ "cmd_effort": "设置 AI effort",
51
+ "cmd_effort_auto": "使用 provider 默认 effort",
52
+ "cmd_effort_max": "使用 Claude max effort",
53
+ "cmd_note": "编辑出题备注",
51
54
  "cmd_notes": "查看保存的备注",
52
55
  "cmd_lang": "设置代码语言",
53
56
  "cmd_ui": "设置 UI 语言",
@@ -4,9 +4,25 @@ Practicode is local-first: user data stays under `.practicode/`, `problems/`, an
4
4
 
5
5
  ## Source Layout
6
6
 
7
- - `src/core.rs` owns problem data, state loading/saving, judging, and file generation.
8
- - `src/core/profile.rs` owns user-profile defaults and normalization.
9
- - `src/tui.rs` owns the Ratatui app shell, event routing, and workflow orchestration.
7
+ - `src/core.rs` is the public core facade. Keep new domain logic in nested `src/core/` modules.
8
+ - `src/core/model.rs` owns persisted/user-facing data shapes and core constants.
9
+ - `src/core/bank.rs` owns local problem-bank loading, saving, starter data, and bank validation.
10
+ - `src/core/state.rs` owns state loading, saving, and settings normalization.
11
+ - `src/core/language.rs` owns language/provider normalization, templates, and extension mapping.
12
+ - `src/core/render.rs` owns plain/markdown problem rendering.
13
+ - `src/core/judge.rs` owns submission file creation, runtime commands, compilation, and judging.
14
+ - `src/core/progress.rs` owns give-up/next/previous/pass history transitions.
15
+ - `src/core/problem_files.rs` owns generated problem README/index file writes.
16
+ - `src/core/profile.rs` owns user-profile defaults and normalization helpers.
17
+ - `src/tui.rs` owns the `PracticodeApp` state shell, construction, run loop, and test accessors. Keep new TUI behavior in nested `src/tui/` modules.
18
+ - `src/tui/actions.rs` owns user actions such as run, next, generate, language/theme/profile changes.
19
+ - `src/tui/command_handlers.rs` owns slash-command routing.
20
+ - `src/tui/command_input.rs` owns command palette input, completion, and Hangul composition.
21
+ - `src/tui/events.rs` owns keyboard/mouse event routing.
22
+ - `src/tui/tasks.rs` owns background AI/update/model tasks and output writing helpers.
23
+ - `src/tui/view.rs` owns Ratatui drawing, pane styling, mouse-capture toggles, and cursor placement.
24
+ - `src/tui/problem_list.rs` owns problem-list rendering and navigation.
25
+ - `src/tui/status.rs` owns status-line text, busy-game text, mode hints, and help text.
10
26
  - `src/tui/commands.rs` owns the command palette catalog.
11
27
  - `src/tui/editor.rs` owns the in-terminal code editor state.
12
28
  - `src/tui/problem_view.rs` owns problem-statement rendering.
@@ -17,7 +33,7 @@ Practicode is local-first: user data stays under `.practicode/`, `problems/`, an
17
33
 
18
34
  ## Extension Rules
19
35
 
20
- - Add domain logic under the owning module first; keep `tui.rs` as orchestration, not a catch-all.
36
+ - Add domain logic under the owning nested module first; keep `core.rs` and `tui.rs` as facades/shells, not catch-alls.
21
37
  - Add user-visible commands in `src/tui/commands.rs`, then route behavior in `PracticodeApp::handle_command`.
22
38
  - Add persisted user profile settings to `Settings`, normalize them in `normalize_settings`, and cover old-state compatibility with tests.
23
39
  - Keep provider-specific behavior in `src/ai.rs`; TUI should ask for status or start tasks, not know provider internals.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "practicode",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Local-first coding-test practice in a Rust terminal UI with optional AI help.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/ai.rs CHANGED
@@ -1,7 +1,7 @@
1
1
  use crate::{
2
2
  core::{
3
- AppState, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings, UI_LANGUAGES,
4
- ensure_submission, normalize_ai_provider, render_problem,
3
+ AppState, CLAUDE_AI_EFFORTS, LANGUAGES, PROBLEM_NOTES_PATH, Problem, Settings,
4
+ UI_LANGUAGES, ensure_submission, normalize_ai_provider, render_problem,
5
5
  },
6
6
  process::{run_capture, sh_quote, shell_process, unique_temp_path, which},
7
7
  };
@@ -21,6 +21,10 @@ pub struct ModelCatalog {
21
21
  pub message: Option<String>,
22
22
  }
23
23
 
24
+ const CODEX_MODEL_FALLBACKS: &[&str] =
25
+ &["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
26
+ const CLAUDE_MODEL_FALLBACKS: &[&str] = &["sonnet", "opus", "fable", "claude-fable-5"];
27
+
24
28
  pub fn run_ai_prompt(root: &Path, problem: &Problem, settings: &Settings, prompt: &str) -> String {
25
29
  let solution = match ensure_submission(root, problem, settings) {
26
30
  Ok(path) => path,
@@ -59,7 +63,8 @@ pub fn run_ai_next(root: &Path, state: &AppState, force: bool, request: &str) ->
59
63
  .current_dir(root)
60
64
  .env("PRACTICODE_NEXT_REQUEST", request)
61
65
  .env("PRACTICODE_AI_PROVIDER", &provider)
62
- .env("PRACTICODE_AI_MODEL", &state.settings.ai_model);
66
+ .env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
67
+ .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort);
63
68
  match run_capture(&mut process, "", Duration::from_secs(900)) {
64
69
  Ok(run) if run.code == Some(0) => {
65
70
  let output = output_text(&run.stdout, &run.stderr);
@@ -91,7 +96,8 @@ pub fn run_ai_generate(root: &Path, state: &AppState, request: &str) -> String {
91
96
  .env("PRACTICODE_NEXT_REQUEST", request)
92
97
  .env("PRACTICODE_GENERATE_BACKGROUND", "1")
93
98
  .env("PRACTICODE_AI_PROVIDER", &provider)
94
- .env("PRACTICODE_AI_MODEL", &state.settings.ai_model);
99
+ .env("PRACTICODE_AI_MODEL", &state.settings.ai_model)
100
+ .env("PRACTICODE_AI_EFFORT", &state.settings.ai_effort);
95
101
  match run_capture(&mut process, "", Duration::from_secs(900)) {
96
102
  Ok(run) if run.code == Some(0) => {
97
103
  let output = output_text(&run.stdout, &run.stderr);
@@ -151,11 +157,15 @@ pub fn available_models(provider: &str) -> ModelCatalog {
151
157
  match normalize_ai_provider(provider).as_str() {
152
158
  "codex" => codex_models(),
153
159
  "claude" => ModelCatalog {
154
- models: Vec::new(),
155
- message: Some(
156
- "Claude CLI does not expose a model list; use /model <name> for a known model."
157
- .to_string(),
158
- ),
160
+ models: CLAUDE_MODEL_FALLBACKS
161
+ .iter()
162
+ .map(|model| (*model).to_string())
163
+ .collect(),
164
+ message: Some(format!(
165
+ "Bundled Claude presets from Claude Code {} --help. Efforts: {}.",
166
+ claude_version().unwrap_or_else(|| "current".to_string()),
167
+ CLAUDE_AI_EFFORTS.join(", ")
168
+ )),
159
169
  },
160
170
  _ => ModelCatalog::default(),
161
171
  }
@@ -231,7 +241,7 @@ fn codex_models() -> ModelCatalog {
231
241
  }
232
242
  if codex_daemon_path().is_none_or(|path| !path.exists()) {
233
243
  return ModelCatalog {
234
- models: Vec::new(),
244
+ models: codex_cached_models(),
235
245
  message: Some("Codex app-server daemon is unavailable; install the standalone Codex app to list models, or use /model <name>.".to_string()),
236
246
  };
237
247
  }
@@ -243,18 +253,22 @@ fn codex_models() -> ModelCatalog {
243
253
  let input = r#"{"id":1,"method":"model/list","params":{"limit":25}}"#;
244
254
  let Ok(run) = run_capture(&mut command, &format!("{input}\n"), Duration::from_secs(2)) else {
245
255
  return ModelCatalog {
246
- models: Vec::new(),
247
- message: Some("Could not query Codex model list.".to_string()),
256
+ models: codex_cached_models(),
257
+ message: Some(
258
+ "Could not query Codex model list; using bundled/cache model presets.".to_string(),
259
+ ),
248
260
  };
249
261
  };
250
262
  if run.code != Some(0) {
251
263
  let detail = output_text(&run.stdout, &run.stderr);
252
264
  return ModelCatalog {
253
- models: Vec::new(),
265
+ models: codex_cached_models(),
254
266
  message: Some(if detail.is_empty() {
255
- "Could not query Codex model list.".to_string()
267
+ "Could not query Codex model list; using bundled/cache model presets.".to_string()
256
268
  } else {
257
- format!("Could not query Codex model list: {detail}")
269
+ format!(
270
+ "Could not query Codex model list; using bundled/cache model presets: {detail}"
271
+ )
258
272
  }),
259
273
  };
260
274
  }
@@ -272,6 +286,51 @@ fn codex_models() -> ModelCatalog {
272
286
  }
273
287
  }
274
288
 
289
+ fn codex_cached_models() -> Vec<String> {
290
+ let mut models = env::var_os("HOME")
291
+ .and_then(|home| {
292
+ fs::read_to_string(PathBuf::from(home).join(".codex/models_cache.json")).ok()
293
+ })
294
+ .and_then(|text| serde_json::from_str::<serde_json::Value>(&text).ok())
295
+ .and_then(|value| {
296
+ value
297
+ .get("models")
298
+ .and_then(|models| models.as_array())
299
+ .cloned()
300
+ })
301
+ .unwrap_or_default()
302
+ .into_iter()
303
+ .filter_map(|model| {
304
+ model
305
+ .get("slug")
306
+ .and_then(|value| value.as_str())
307
+ .map(str::to_string)
308
+ })
309
+ .collect::<Vec<_>>();
310
+ if models.is_empty() {
311
+ models = CODEX_MODEL_FALLBACKS
312
+ .iter()
313
+ .map(|model| (*model).to_string())
314
+ .collect();
315
+ }
316
+ models
317
+ }
318
+
319
+ fn claude_version() -> Option<String> {
320
+ which("claude").and_then(|_| {
321
+ let mut command = Command::new("claude");
322
+ command.arg("--version");
323
+ run_capture(&mut command, "", Duration::from_secs(2))
324
+ .ok()
325
+ .and_then(|run| {
326
+ output_text(&run.stdout, &run.stderr)
327
+ .lines()
328
+ .next()
329
+ .map(str::to_string)
330
+ })
331
+ })
332
+ }
333
+
275
334
  fn parse_model_list(output: &str) -> Vec<String> {
276
335
  output
277
336
  .lines()
@@ -309,6 +368,7 @@ fn run_codex_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
309
368
  if let Some(model) = settings.model_arg() {
310
369
  command.args(["--model", model]);
311
370
  }
371
+ add_codex_effort_args(&mut command, settings);
312
372
  command.args(["-o", &output_path.display().to_string(), prompt]);
313
373
  let result = run_capture(&mut command, "", Duration::from_secs(600));
314
374
  let last_message = fs::read_to_string(&output_path).unwrap_or_default();
@@ -341,6 +401,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
341
401
  if let Some(model) = settings.model_arg() {
342
402
  command.args(["--model", model]);
343
403
  }
404
+ if let Some(effort) = settings.effort_arg() {
405
+ command.args(["--effort", effort]);
406
+ }
344
407
  command.args(["-p", prompt]);
345
408
  match run_capture(&mut command, "", Duration::from_secs(600)) {
346
409
  Ok(run) if run.code == Some(0) => {
@@ -371,6 +434,7 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
371
434
  if let Some(model) = settings.model_arg() {
372
435
  exec.push_str(&format!(" --model {}", sh_quote(model)));
373
436
  }
437
+ push_codex_effort_arg(&mut exec, settings);
374
438
  exec.push(' ');
375
439
  exec.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
376
440
  settings, request,
@@ -387,6 +451,7 @@ fn default_codex_generate_command(root: &Path, settings: &Settings, request: &st
387
451
  if let Some(model) = settings.model_arg() {
388
452
  exec.push_str(&format!(" --model {}", sh_quote(model)));
389
453
  }
454
+ push_codex_effort_arg(&mut exec, settings);
390
455
  exec.push(' ');
391
456
  exec.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
392
457
  settings, request,
@@ -407,6 +472,9 @@ fn default_claude_next_command(root: &Path, settings: &Settings, request: &str)
407
472
  if let Some(model) = settings.model_arg() {
408
473
  claude.push_str(&format!(" --model {}", sh_quote(model)));
409
474
  }
475
+ if let Some(effort) = settings.effort_arg() {
476
+ claude.push_str(&format!(" --effort {}", sh_quote(effort)));
477
+ }
410
478
  claude.push_str(" -p ");
411
479
  claude.push_str(&sh_quote(&default_ai_next_prompt_with_settings(
412
480
  settings, request,
@@ -423,6 +491,9 @@ fn default_claude_generate_command(root: &Path, settings: &Settings, request: &s
423
491
  if let Some(model) = settings.model_arg() {
424
492
  claude.push_str(&format!(" --model {}", sh_quote(model)));
425
493
  }
494
+ if let Some(effort) = settings.effort_arg() {
495
+ claude.push_str(&format!(" --effort {}", sh_quote(effort)));
496
+ }
426
497
  claude.push_str(" -p ");
427
498
  claude.push_str(&sh_quote(&default_ai_generate_prompt_with_settings(
428
499
  settings, request,
@@ -442,6 +513,23 @@ fn output_text(stdout: &str, stderr: &str) -> String {
442
513
  .join("\n")
443
514
  }
444
515
 
516
+ fn add_codex_effort_args(command: &mut Command, settings: &Settings) {
517
+ if let Some(effort) = settings.effort_arg() {
518
+ let effort = if effort == "max" { "xhigh" } else { effort };
519
+ command.args(["-c", &format!("model_reasoning_effort=\"{effort}\"")]);
520
+ }
521
+ }
522
+
523
+ fn push_codex_effort_arg(command: &mut String, settings: &Settings) {
524
+ if let Some(effort) = settings.effort_arg() {
525
+ let effort = if effort == "max" { "xhigh" } else { effort };
526
+ command.push_str(&format!(
527
+ " -c {}",
528
+ sh_quote(&format!("model_reasoning_effort=\"{effort}\""))
529
+ ));
530
+ }
531
+ }
532
+
445
533
  fn list_or_none(values: &[String]) -> String {
446
534
  if values.is_empty() {
447
535
  "(none)".to_string()
@@ -0,0 +1,148 @@
1
+ use super::*;
2
+
3
+ pub fn starter_problem() -> Problem {
4
+ Problem {
5
+ id: "001-hello-world".to_string(),
6
+ slug: "hello-world".to_string(),
7
+ difficulty: "easy".to_string(),
8
+ topics: vec!["io".to_string()],
9
+ title: localized_map(&[
10
+ ("en", "Hello World"),
11
+ ("ko", "Hello World"),
12
+ ("ja", "Hello World"),
13
+ ("zh", "Hello World"),
14
+ ("es", "Hello World"),
15
+ ]),
16
+ statement: localized_map(&[
17
+ ("en", "Print exactly `Hello, World!` to stdout."),
18
+ ("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
19
+ ("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
20
+ ("zh", "向标准输出准确打印 `Hello, World!`。"),
21
+ ("es", "Imprime exactamente `Hello, World!` en stdout."),
22
+ ]),
23
+ input: localized_map(&[
24
+ ("en", "No input."),
25
+ ("ko", "입력은 없습니다."),
26
+ ("ja", "入力はありません。"),
27
+ ("zh", "没有输入。"),
28
+ ("es", "No hay entrada."),
29
+ ]),
30
+ output: localized_map(&[
31
+ ("en", "One line: `Hello, World!`"),
32
+ ("ko", "`Hello, World!` 한 줄"),
33
+ ("ja", "1行: `Hello, World!`"),
34
+ ("zh", "一行: `Hello, World!`"),
35
+ ("es", "Una linea: `Hello, World!`"),
36
+ ]),
37
+ examples: vec![IoCase {
38
+ input: String::new(),
39
+ output: "Hello, World!\n".to_string(),
40
+ }],
41
+ cases: vec![IoCase {
42
+ input: String::new(),
43
+ output: "Hello, World!\n".to_string(),
44
+ }],
45
+ answers: HashMap::from([
46
+ ("python".to_string(), "print('Hello, World!')\n".to_string()),
47
+ (
48
+ "ts".to_string(),
49
+ "console.log('Hello, World!');\n".to_string(),
50
+ ),
51
+ (
52
+ "java".to_string(),
53
+ "class Solution {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n".to_string(),
54
+ ),
55
+ (
56
+ "rust".to_string(),
57
+ "fn main() {\n println!(\"Hello, World!\");\n}\n".to_string(),
58
+ ),
59
+ ]),
60
+ }
61
+ }
62
+
63
+ pub fn map2(k1: &str, v1: &str, k2: &str, v2: &str) -> HashMap<String, String> {
64
+ HashMap::from([
65
+ (k1.to_string(), v1.to_string()),
66
+ (k2.to_string(), v2.to_string()),
67
+ ])
68
+ }
69
+
70
+ pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
71
+ entries
72
+ .iter()
73
+ .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
74
+ .collect()
75
+ }
76
+
77
+ pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {
78
+ let path = root.join(BANK_PATH);
79
+ if path.exists() {
80
+ let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
81
+ let bank: Vec<Problem> =
82
+ serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
83
+ validate_bank(&bank, &path)?;
84
+ Ok(bank)
85
+ } else {
86
+ Ok(vec![starter_problem()])
87
+ }
88
+ }
89
+
90
+ pub fn save_bank(root: &Path, bank: &[Problem]) -> Result<()> {
91
+ let path = root.join(BANK_PATH);
92
+ validate_bank(bank, &path)?;
93
+ if let Some(parent) = path.parent() {
94
+ fs::create_dir_all(parent)?;
95
+ }
96
+ fs::write(&path, serde_json::to_string_pretty(bank)? + "\n")?;
97
+ Ok(())
98
+ }
99
+
100
+ fn validate_bank(bank: &[Problem], path: &Path) -> Result<()> {
101
+ if bank.is_empty() {
102
+ bail!("{} must contain at least one problem", path.display());
103
+ }
104
+ for problem in bank {
105
+ if !is_safe_name(&problem.id) {
106
+ bail!("{} has invalid problem id {:?}", path.display(), problem.id);
107
+ }
108
+ if !is_safe_name(&problem.slug) {
109
+ bail!(
110
+ "{} has invalid slug {:?} for {}",
111
+ path.display(),
112
+ problem.slug,
113
+ problem.id
114
+ );
115
+ }
116
+ if problem.cases.is_empty() {
117
+ bail!(
118
+ "{} problem {} has no judge cases",
119
+ path.display(),
120
+ problem.id
121
+ );
122
+ }
123
+ if problem.answers.is_empty() {
124
+ bail!(
125
+ "{} problem {} must contain at least one answer",
126
+ path.display(),
127
+ problem.id
128
+ );
129
+ }
130
+ for language in problem.answers.keys() {
131
+ if !LANGUAGES.contains(&language.as_str()) {
132
+ bail!(
133
+ "{} problem {} has unsupported answer language {language}",
134
+ path.display(),
135
+ problem.id,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ Ok(())
141
+ }
142
+
143
+ fn is_safe_name(value: &str) -> bool {
144
+ !value.is_empty()
145
+ && value
146
+ .chars()
147
+ .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_'))
148
+ }