practicode 0.1.0 → 0.1.2

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.
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ current=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
5
+ npm_current=$(node -p "require('./package.json').version")
6
+
7
+ if [[ "$current" != "$npm_current" ]]; then
8
+ echo "Cargo.toml version ($current) does not match package.json ($npm_current)" >&2
9
+ exit 1
10
+ fi
11
+
12
+ if [[ -n "$(git status --porcelain)" ]]; then
13
+ echo "working tree is dirty; commit or stash changes first" >&2
14
+ exit 1
15
+ fi
16
+
17
+ branch=$(git branch --show-current)
18
+ if [[ "$branch" != "main" ]]; then
19
+ echo "release from main, not $branch" >&2
20
+ exit 1
21
+ fi
22
+
23
+ git fetch origin main --tags
24
+ if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then
25
+ echo "local main is not synced with origin/main" >&2
26
+ exit 1
27
+ fi
28
+
29
+ echo "Current version: $current"
30
+ version="${1:-}"
31
+ if [[ -z "$version" ]]; then
32
+ read -r -p "Next version: " version
33
+ fi
34
+
35
+ if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
36
+ echo "version must look like 0.1.1" >&2
37
+ exit 2
38
+ fi
39
+
40
+ node -e '
41
+ const [current, next] = process.argv.slice(1).map(v => v.split(".").map(Number));
42
+ const ok = next[0] > current[0] ||
43
+ (next[0] === current[0] && (next[1] > current[1] ||
44
+ (next[1] === current[1] && next[2] > current[2])));
45
+ process.exit(ok ? 0 : 1);
46
+ ' "$current" "$version" || {
47
+ echo "next version must be greater than $current" >&2
48
+ exit 2
49
+ }
50
+
51
+ if git rev-parse -q --verify "refs/tags/v$version" >/dev/null; then
52
+ echo "tag v$version already exists locally" >&2
53
+ exit 1
54
+ fi
55
+ if git ls-remote --exit-code --tags origin "v$version" >/dev/null 2>&1; then
56
+ echo "tag v$version already exists on origin" >&2
57
+ exit 1
58
+ fi
59
+
60
+ echo "Releasing v$version"
61
+ VERSION="$version" perl -0pi -e 's/^version = ".*"/version = "$ENV{VERSION}"/m' Cargo.toml
62
+ node -e "const fs=require('fs'); const p=require('./package.json'); p.version=process.env.VERSION; fs.writeFileSync('package.json', JSON.stringify(p, null, 2) + '\n')"
63
+ cargo test
64
+ npm pack --dry-run >/dev/null
65
+
66
+ git add Cargo.toml Cargo.lock package.json
67
+ git commit -m "Release v$version"
68
+ git tag "v$version"
69
+ git push origin main "v$version"
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,32 @@ 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
+
81
105
  pub fn default_ai_next_prompt(request: &str) -> String {
82
106
  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.",
107
+ "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
108
  if request.is_empty() {
85
109
  "(none)"
86
110
  } else {
@@ -177,9 +201,9 @@ fn run_claude_prompt(root: &Path, settings: &Settings, prompt: &str) -> String {
177
201
  }
178
202
 
179
203
  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";
204
+ let start = "if [ -x \"$HOME/.codex/packages/standalone/current/codex\" ]; then codex app-server daemon start >/dev/null 2>&1 || true; fi";
181
205
  let mut exec = format!(
182
- "codex exec --cd {} --sandbox workspace-write",
206
+ "codex exec --ephemeral --cd {} --sandbox workspace-write",
183
207
  sh_quote(&root.display().to_string())
184
208
  );
185
209
  if let Some(model) = settings.model_arg() {
@@ -190,6 +214,14 @@ fn default_codex_next_command(root: &Path, settings: &Settings, request: &str) -
190
214
  format!("{start}; {exec}")
191
215
  }
192
216
 
217
+ fn codex_daemon_path() -> Option<PathBuf> {
218
+ env::var_os("HOME").map(|home| {
219
+ PathBuf::from(home)
220
+ .join(".codex/packages/standalone/current")
221
+ .join(if cfg!(windows) { "codex.exe" } else { "codex" })
222
+ })
223
+ }
224
+
193
225
  fn default_claude_next_command(root: &Path, settings: &Settings, request: &str) -> String {
194
226
  let mut claude = "claude --permission-mode acceptEdits".to_string();
195
227
  if let Some(model) = settings.model_arg() {
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] = &["ko", "en"];
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";
@@ -120,7 +120,7 @@ pub fn default_language() -> String {
120
120
  }
121
121
 
122
122
  pub fn default_ui_language() -> String {
123
- "ko".to_string()
123
+ "en".to_string()
124
124
  }
125
125
 
126
126
  pub fn default_theme() -> String {
@@ -163,20 +163,34 @@ pub fn starter_problem() -> Problem {
163
163
  slug: "hello-world".to_string(),
164
164
  difficulty: "easy".to_string(),
165
165
  topics: vec!["io".to_string()],
166
- title: map2("ko", "Hello World", "en", "Hello World"),
167
- statement: map2(
168
- "ko",
169
- "표준 출력으로 정확히 `Hello, World!`를 출력하세요.",
170
- "en",
171
- "Print exactly `Hello, World!` to stdout.",
172
- ),
173
- input: map2("ko", "입력은 없습니다.", "en", "No input."),
174
- output: map2(
175
- "ko",
176
- "`Hello, World!` 한 줄",
177
- "en",
178
- "One line: `Hello, World!`",
179
- ),
166
+ title: localized_map(&[
167
+ ("en", "Hello World"),
168
+ ("ko", "Hello World"),
169
+ ("ja", "Hello World"),
170
+ ("zh", "Hello World"),
171
+ ("es", "Hello World"),
172
+ ]),
173
+ statement: localized_map(&[
174
+ ("en", "Print exactly `Hello, World!` to stdout."),
175
+ ("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
176
+ ("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
177
+ ("zh", "向标准输出准确打印 `Hello, World!`。"),
178
+ ("es", "Imprime exactamente `Hello, World!` en stdout."),
179
+ ]),
180
+ input: localized_map(&[
181
+ ("en", "No input."),
182
+ ("ko", "입력은 없습니다."),
183
+ ("ja", "入力はありません。"),
184
+ ("zh", "没有输入。"),
185
+ ("es", "No hay entrada."),
186
+ ]),
187
+ output: localized_map(&[
188
+ ("en", "One line: `Hello, World!`"),
189
+ ("ko", "`Hello, World!` 한 줄"),
190
+ ("ja", "1行: `Hello, World!`"),
191
+ ("zh", "一行: `Hello, World!`"),
192
+ ("es", "Una linea: `Hello, World!`"),
193
+ ]),
180
194
  examples: vec![IoCase {
181
195
  input: String::new(),
182
196
  output: "Hello, World!\n".to_string(),
@@ -210,6 +224,13 @@ pub fn map2(k1: &str, v1: &str, k2: &str, v2: &str) -> HashMap<String, String> {
210
224
  ])
211
225
  }
212
226
 
227
+ pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
228
+ entries
229
+ .iter()
230
+ .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
231
+ .collect()
232
+ }
233
+
213
234
  pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {
214
235
  let path = root.join(BANK_PATH);
215
236
  if path.exists() {
@@ -339,9 +360,7 @@ pub fn save_state(root: &Path, state: &AppState) -> Result<()> {
339
360
 
340
361
  pub fn normalize_settings(settings: &mut Settings) {
341
362
  settings.language = normalize_language(&settings.language);
342
- if !UI_LANGUAGES.contains(&settings.ui_language.as_str()) {
343
- settings.ui_language = "ko".to_string();
344
- }
363
+ settings.ui_language = normalize_ui_language(&settings.ui_language);
345
364
  if !THEMES.contains(&settings.theme.as_str()) {
346
365
  settings.theme = "dark".to_string();
347
366
  }
@@ -381,9 +400,10 @@ pub fn normalize_ai_provider(provider: &str) -> String {
381
400
  }
382
401
 
383
402
  pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
384
- map.get(lang)
385
- .or_else(|| map.get("ko"))
403
+ let lang = normalize_ui_language(lang);
404
+ map.get(lang.as_str())
386
405
  .or_else(|| map.get("en"))
406
+ .or_else(|| map.get("ko"))
387
407
  .or_else(|| map.values().next())
388
408
  .cloned()
389
409
  .unwrap_or_default()
@@ -415,20 +435,19 @@ pub fn ensure_submission(root: &Path, problem: &Problem, settings: &Settings) ->
415
435
  }
416
436
 
417
437
  pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
418
- let lang = if UI_LANGUAGES.contains(&ui_language) {
419
- ui_language
420
- } else {
421
- "ko"
422
- };
438
+ let lang = normalize_ui_language(ui_language);
423
439
  let examples = problem
424
440
  .examples
425
441
  .iter()
426
442
  .enumerate()
427
443
  .map(|(index, case)| {
428
444
  format!(
429
- "### Example {}\n\nInput\n\n{}\n\nOutput\n\n{}",
445
+ "### {} {}\n\n{}\n\n{}\n\n{}\n\n{}",
446
+ ui_text(&lang, "example"),
430
447
  index + 1,
448
+ ui_text(&lang, "input"),
431
449
  fenced_text(&case.input),
450
+ ui_text(&lang, "output"),
432
451
  fenced_text(&case.output)
433
452
  )
434
453
  })
@@ -440,17 +459,82 @@ pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
440
459
  .map(|(number, _)| number)
441
460
  .unwrap_or(&problem.id);
442
461
  format!(
443
- "# {number}. {}\n\nDifficulty: {}\nTopics: {}\n\n{}\n\n## Input\n\n{}\n\n## Output\n\n{}\n\n## Examples\n\n{}",
444
- localized(&problem.title, lang),
462
+ "# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}",
463
+ localized(&problem.title, &lang),
464
+ ui_text(&lang, "difficulty"),
445
465
  problem.difficulty,
466
+ ui_text(&lang, "topics"),
446
467
  problem.topics.join(", "),
447
- localized(&problem.statement, lang),
448
- localized(&problem.input, lang),
449
- localized(&problem.output, lang),
468
+ localized(&problem.statement, &lang),
469
+ ui_text(&lang, "input"),
470
+ localized(&problem.input, &lang),
471
+ ui_text(&lang, "output"),
472
+ localized(&problem.output, &lang),
473
+ ui_text(&lang, "examples"),
450
474
  examples
451
475
  )
452
476
  }
453
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
+
454
538
  pub fn fenced_text(value: &str) -> String {
455
539
  let mut body = value.to_string();
456
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")?;