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/src/core.rs CHANGED
@@ -9,990 +9,25 @@ use std::{
9
9
  time::Duration,
10
10
  };
11
11
 
12
+ mod bank;
13
+ mod judge;
14
+ mod language;
15
+ mod model;
16
+ mod problem_files;
12
17
  mod profile;
18
+ mod progress;
19
+ mod render;
20
+ mod state;
21
+
22
+ pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
23
+ pub use bank::*;
24
+ pub use judge::*;
25
+ pub use language::*;
26
+ pub use model::*;
27
+ pub use problem_files::*;
13
28
  pub use profile::{
14
29
  DIFFICULTIES, default_difficulty, normalize_difficulty, normalize_topic_list, parse_topic_list,
15
30
  };
16
-
17
- pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
18
- pub use crate::i18n::{UI_LANGUAGES, normalize_ui_language, ui_text};
19
- pub const THEMES: &[&str] = &["dark", "light"];
20
- pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
21
- pub const BANK_PATH: &str = ".practicode/problem_bank.json";
22
- pub const STATE_PATH: &str = ".practicode/problem-state.json";
23
- pub const PROBLEM_NOTES_PATH: &str = ".practicode/problem_notes.md";
24
-
25
- #[derive(Clone, Debug, Serialize, Deserialize)]
26
- pub struct Settings {
27
- #[serde(default = "default_language")]
28
- pub language: String,
29
- #[serde(default = "default_ui_language")]
30
- pub ui_language: String,
31
- #[serde(default = "default_theme")]
32
- pub theme: String,
33
- #[serde(default = "default_difficulty")]
34
- pub difficulty: String,
35
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
36
- pub topics: Vec<String>,
37
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
38
- pub avoid_topics: Vec<String>,
39
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
40
- pub generate_languages: Vec<String>,
41
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
42
- pub generate_ui_languages: Vec<String>,
43
- #[serde(default = "default_editor")]
44
- pub editor: String,
45
- #[serde(default = "default_next_source")]
46
- pub next_source: String,
47
- #[serde(default = "default_ai_provider")]
48
- pub ai_provider: String,
49
- #[serde(default = "default_ai_model")]
50
- pub ai_model: String,
51
- #[serde(default, skip_serializing_if = "String::is_empty")]
52
- pub ai_next_command: String,
53
- }
54
-
55
- impl Default for Settings {
56
- fn default() -> Self {
57
- Self {
58
- language: default_language(),
59
- ui_language: default_ui_language(),
60
- theme: default_theme(),
61
- difficulty: default_difficulty(),
62
- topics: Vec::new(),
63
- avoid_topics: Vec::new(),
64
- generate_languages: Vec::new(),
65
- generate_ui_languages: Vec::new(),
66
- editor: default_editor(),
67
- next_source: default_next_source(),
68
- ai_provider: default_ai_provider(),
69
- ai_model: default_ai_model(),
70
- ai_next_command: String::new(),
71
- }
72
- }
73
- }
74
-
75
- impl Settings {
76
- pub fn next_ai_command(&self) -> &str {
77
- &self.ai_next_command
78
- }
79
-
80
- pub fn model_arg(&self) -> Option<&str> {
81
- let model = self.ai_model.trim();
82
- if model.is_empty() || model == "auto" {
83
- None
84
- } else {
85
- Some(model)
86
- }
87
- }
88
- }
89
-
90
- #[derive(Clone, Debug, Serialize, Deserialize)]
91
- pub struct HistoryItem {
92
- pub id: String,
93
- pub status: String,
94
- }
95
-
96
- #[derive(Clone, Debug, Serialize, Deserialize)]
97
- pub struct AppState {
98
- pub current_problem: String,
99
- #[serde(default)]
100
- pub settings: Settings,
101
- #[serde(default)]
102
- pub solved: Vec<String>,
103
- #[serde(default)]
104
- pub history: Vec<HistoryItem>,
105
- #[serde(default = "default_suggested_difficulty")]
106
- pub suggested_next_difficulty: String,
107
- }
108
-
109
- #[derive(Clone, Debug, Serialize, Deserialize)]
110
- pub struct Problem {
111
- pub id: String,
112
- pub slug: String,
113
- pub difficulty: String,
114
- pub topics: Vec<String>,
115
- pub title: HashMap<String, String>,
116
- pub statement: HashMap<String, String>,
117
- pub input: HashMap<String, String>,
118
- pub output: HashMap<String, String>,
119
- pub examples: Vec<IoCase>,
120
- pub cases: Vec<IoCase>,
121
- pub answers: HashMap<String, String>,
122
- }
123
-
124
- #[derive(Clone, Debug, Serialize, Deserialize)]
125
- pub struct IoCase {
126
- pub input: String,
127
- pub output: String,
128
- }
129
-
130
- #[derive(Clone, Debug)]
131
- pub struct JudgeResult {
132
- pub passed: bool,
133
- pub passed_cases: usize,
134
- pub total_cases: usize,
135
- pub output: String,
136
- }
137
-
138
- pub fn default_language() -> String {
139
- "python".to_string()
140
- }
141
-
142
- pub fn default_ui_language() -> String {
143
- "en".to_string()
144
- }
145
-
146
- pub fn default_theme() -> String {
147
- "dark".to_string()
148
- }
149
-
150
- pub fn default_editor() -> String {
151
- "vim".to_string()
152
- }
153
-
154
- pub fn default_next_source() -> String {
155
- "bank".to_string()
156
- }
157
-
158
- pub fn default_ai_provider() -> String {
159
- "codex".to_string()
160
- }
161
-
162
- pub fn default_ai_model() -> String {
163
- "auto".to_string()
164
- }
165
-
166
- pub fn default_suggested_difficulty() -> String {
167
- "easy".to_string()
168
- }
169
-
170
- pub fn ext_for(language: &str) -> &'static str {
171
- match normalize_language(language).as_str() {
172
- "python" => "py",
173
- "ts" => "ts",
174
- "java" => "java",
175
- "rust" => "rs",
176
- _ => "py",
177
- }
178
- }
179
-
180
- pub fn starter_problem() -> Problem {
181
- Problem {
182
- id: "001-hello-world".to_string(),
183
- slug: "hello-world".to_string(),
184
- difficulty: "easy".to_string(),
185
- topics: vec!["io".to_string()],
186
- title: localized_map(&[
187
- ("en", "Hello World"),
188
- ("ko", "Hello World"),
189
- ("ja", "Hello World"),
190
- ("zh", "Hello World"),
191
- ("es", "Hello World"),
192
- ]),
193
- statement: localized_map(&[
194
- ("en", "Print exactly `Hello, World!` to stdout."),
195
- ("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
196
- ("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
197
- ("zh", "向标准输出准确打印 `Hello, World!`。"),
198
- ("es", "Imprime exactamente `Hello, World!` en stdout."),
199
- ]),
200
- input: localized_map(&[
201
- ("en", "No input."),
202
- ("ko", "입력은 없습니다."),
203
- ("ja", "入力はありません。"),
204
- ("zh", "没有输入。"),
205
- ("es", "No hay entrada."),
206
- ]),
207
- output: localized_map(&[
208
- ("en", "One line: `Hello, World!`"),
209
- ("ko", "`Hello, World!` 한 줄"),
210
- ("ja", "1行: `Hello, World!`"),
211
- ("zh", "一行: `Hello, World!`"),
212
- ("es", "Una linea: `Hello, World!`"),
213
- ]),
214
- examples: vec![IoCase {
215
- input: String::new(),
216
- output: "Hello, World!\n".to_string(),
217
- }],
218
- cases: vec![IoCase {
219
- input: String::new(),
220
- output: "Hello, World!\n".to_string(),
221
- }],
222
- answers: HashMap::from([
223
- ("python".to_string(), "print('Hello, World!')\n".to_string()),
224
- (
225
- "ts".to_string(),
226
- "console.log('Hello, World!');\n".to_string(),
227
- ),
228
- (
229
- "java".to_string(),
230
- "class Solution {\n public static void main(String[] args) {\n System.out.println(\"Hello, World!\");\n }\n}\n".to_string(),
231
- ),
232
- (
233
- "rust".to_string(),
234
- "fn main() {\n println!(\"Hello, World!\");\n}\n".to_string(),
235
- ),
236
- ]),
237
- }
238
- }
239
-
240
- pub fn map2(k1: &str, v1: &str, k2: &str, v2: &str) -> HashMap<String, String> {
241
- HashMap::from([
242
- (k1.to_string(), v1.to_string()),
243
- (k2.to_string(), v2.to_string()),
244
- ])
245
- }
246
-
247
- pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
248
- entries
249
- .iter()
250
- .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
251
- .collect()
252
- }
253
-
254
- pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {
255
- let path = root.join(BANK_PATH);
256
- if path.exists() {
257
- let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
258
- let bank: Vec<Problem> =
259
- serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
260
- validate_bank(&bank, &path)?;
261
- Ok(bank)
262
- } else {
263
- Ok(vec![starter_problem()])
264
- }
265
- }
266
-
267
- pub fn save_bank(root: &Path, bank: &[Problem]) -> Result<()> {
268
- let path = root.join(BANK_PATH);
269
- validate_bank(bank, &path)?;
270
- if let Some(parent) = path.parent() {
271
- fs::create_dir_all(parent)?;
272
- }
273
- fs::write(&path, serde_json::to_string_pretty(bank)? + "\n")?;
274
- Ok(())
275
- }
276
-
277
- fn validate_bank(bank: &[Problem], path: &Path) -> Result<()> {
278
- if bank.is_empty() {
279
- bail!("{} must contain at least one problem", path.display());
280
- }
281
- for problem in bank {
282
- if !is_safe_name(&problem.id) {
283
- bail!("{} has invalid problem id {:?}", path.display(), problem.id);
284
- }
285
- if !is_safe_name(&problem.slug) {
286
- bail!(
287
- "{} has invalid slug {:?} for {}",
288
- path.display(),
289
- problem.slug,
290
- problem.id
291
- );
292
- }
293
- if problem.cases.is_empty() {
294
- bail!(
295
- "{} problem {} has no judge cases",
296
- path.display(),
297
- problem.id
298
- );
299
- }
300
- if problem.answers.is_empty() {
301
- bail!(
302
- "{} problem {} must contain at least one answer",
303
- path.display(),
304
- problem.id
305
- );
306
- }
307
- for language in problem.answers.keys() {
308
- if !LANGUAGES.contains(&language.as_str()) {
309
- bail!(
310
- "{} problem {} has unsupported answer language {language}",
311
- path.display(),
312
- problem.id,
313
- );
314
- }
315
- }
316
- }
317
- Ok(())
318
- }
319
-
320
- fn is_safe_name(value: &str) -> bool {
321
- !value.is_empty()
322
- && value
323
- .chars()
324
- .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_'))
325
- }
326
-
327
- pub fn load_state(root: &Path, bank: &[Problem]) -> Result<AppState> {
328
- let path = root.join(STATE_PATH);
329
- if !path.exists() {
330
- return Ok(AppState {
331
- current_problem: bank[0].id.clone(),
332
- settings: Settings::default(),
333
- solved: Vec::new(),
334
- history: vec![HistoryItem {
335
- id: bank[0].id.clone(),
336
- status: "assigned".to_string(),
337
- }],
338
- suggested_next_difficulty: "easy".to_string(),
339
- });
340
- }
341
-
342
- let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
343
- let mut state: AppState =
344
- serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
345
- if !bank
346
- .iter()
347
- .any(|problem| problem.id == state.current_problem)
348
- {
349
- state.current_problem = bank[0].id.clone();
350
- }
351
- normalize_settings(&mut state.settings);
352
- if state.history.is_empty() {
353
- state.history.push(HistoryItem {
354
- id: state.current_problem.clone(),
355
- status: "assigned".to_string(),
356
- });
357
- }
358
- Ok(state)
359
- }
360
-
361
- pub fn save_state(root: &Path, state: &AppState) -> Result<()> {
362
- #[derive(Serialize)]
363
- struct StateFile<'a> {
364
- current_problem: &'a str,
365
- next_number: usize,
366
- suggested_next_difficulty: &'a str,
367
- settings: &'a Settings,
368
- solved: &'a [String],
369
- history: &'a [HistoryItem],
370
- }
371
-
372
- let path = root.join(STATE_PATH);
373
- if let Some(parent) = path.parent() {
374
- fs::create_dir_all(parent)?;
375
- }
376
- let file = StateFile {
377
- current_problem: &state.current_problem,
378
- next_number: state.history.len() + 1,
379
- suggested_next_difficulty: &state.suggested_next_difficulty,
380
- settings: &state.settings,
381
- solved: &state.solved,
382
- history: &state.history,
383
- };
384
- fs::write(path, serde_json::to_string_pretty(&file)? + "\n")?;
385
- Ok(())
386
- }
387
-
388
- pub fn normalize_settings(settings: &mut Settings) {
389
- settings.language = normalize_language(&settings.language);
390
- settings.ui_language = normalize_ui_language(&settings.ui_language);
391
- if !THEMES.contains(&settings.theme.as_str()) {
392
- settings.theme = "dark".to_string();
393
- }
394
- settings.difficulty = normalize_difficulty(&settings.difficulty);
395
- settings.topics = normalize_topic_list(&settings.topics);
396
- settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
397
- settings.generate_languages = normalize_language_list(&settings.generate_languages);
398
- settings.generate_ui_languages = normalize_ui_language_list(&settings.generate_ui_languages);
399
- settings.next_source = normalize_next_source(&settings.next_source);
400
- settings.ai_provider = normalize_ai_provider(&settings.ai_provider);
401
- if settings.ai_model.trim().is_empty() {
402
- settings.ai_model = default_ai_model();
403
- }
404
- }
405
-
406
- pub fn problem_by_id<'a>(bank: &'a [Problem], problem_id: &str) -> Option<&'a Problem> {
407
- bank.iter().find(|problem| problem.id == problem_id)
408
- }
409
-
410
- pub fn normalize_language(language: &str) -> String {
411
- let language = language.trim().to_lowercase();
412
- if LANGUAGES.contains(&language.as_str()) {
413
- language
414
- } else {
415
- "python".to_string()
416
- }
417
- }
418
-
419
- pub fn parse_language_list(value: &str) -> Vec<String> {
420
- let mut languages = Vec::new();
421
- for language in value.split(',') {
422
- let language = language.trim().to_lowercase();
423
- if language == "all" {
424
- return Vec::new();
425
- }
426
- if LANGUAGES.contains(&language.as_str()) && !languages.contains(&language) {
427
- languages.push(language);
428
- }
429
- }
430
- languages
431
- }
432
-
433
- pub fn normalize_language_list(languages: &[String]) -> Vec<String> {
434
- parse_language_list(&languages.join(","))
435
- }
436
-
437
- pub fn parse_ui_language_list(value: &str) -> Vec<String> {
438
- let mut languages = Vec::new();
439
- for language in value.split(',') {
440
- let lower = language.trim().to_lowercase();
441
- if lower == "all" {
442
- return Vec::new();
443
- }
444
- let language = lower
445
- .split(['-', '_'])
446
- .next()
447
- .filter(|value| UI_LANGUAGES.contains(value))
448
- .unwrap_or("");
449
- if language == "all" {
450
- return Vec::new();
451
- }
452
- if !language.is_empty() && !languages.iter().any(|value| value == language) {
453
- languages.push(language.to_string());
454
- }
455
- }
456
- languages
457
- }
458
-
459
- pub fn normalize_ui_language_list(languages: &[String]) -> Vec<String> {
460
- parse_ui_language_list(&languages.join(","))
461
- }
462
-
463
- pub fn normalize_next_source(source: &str) -> String {
464
- if source == "ai" {
465
- "ai".to_string()
466
- } else {
467
- "bank".to_string()
468
- }
469
- }
470
-
471
- pub fn normalize_ai_provider(provider: &str) -> String {
472
- if provider == "claude" {
473
- "claude".to_string()
474
- } else {
475
- "codex".to_string()
476
- }
477
- }
478
-
479
- pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
480
- let lang = normalize_ui_language(lang);
481
- map.get(lang.as_str())
482
- .or_else(|| map.get("en"))
483
- .or_else(|| map.get("ko"))
484
- .or_else(|| map.values().next())
485
- .cloned()
486
- .unwrap_or_default()
487
- }
488
-
489
- pub fn template_for(language: &str) -> String {
490
- match normalize_language(language).as_str() {
491
- "python" => "# Read from stdin and print to stdout.\nimport sys\n\n\n".to_string(),
492
- "ts" => "const fs = require('fs');\nconst input = fs.readFileSync(0, 'utf8');\n\n".to_string(),
493
- "java" => "import java.io.*;\n\nclass Solution {\n public static void main(String[] args) throws Exception {\n }\n}\n".to_string(),
494
- "rust" => "fn main() {\n}\n".to_string(),
495
- _ => String::new(),
496
- }
497
- }
498
-
499
- pub fn ensure_submission(root: &Path, problem: &Problem, settings: &Settings) -> Result<PathBuf> {
500
- let language = normalize_language(&settings.language);
501
- let path = root
502
- .join("submissions")
503
- .join(&problem.id)
504
- .join(format!("solution.{}", ext_for(&language)));
505
- if !path.exists() {
506
- if let Some(parent) = path.parent() {
507
- fs::create_dir_all(parent)?;
508
- }
509
- fs::write(&path, template_for(&language))?;
510
- }
511
- Ok(path)
512
- }
513
-
514
- pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
515
- let lang = normalize_ui_language(ui_language);
516
- let examples = problem
517
- .examples
518
- .iter()
519
- .enumerate()
520
- .map(|(index, case)| {
521
- format!(
522
- "### {} {}\n\n{}\n\n{}\n\n{}\n\n{}",
523
- ui_text(&lang, "example"),
524
- index + 1,
525
- ui_text(&lang, "input"),
526
- fenced_text(&case.input),
527
- ui_text(&lang, "output"),
528
- fenced_text(&case.output)
529
- )
530
- })
531
- .collect::<Vec<_>>()
532
- .join("\n\n");
533
- let number = problem
534
- .id
535
- .split_once('-')
536
- .map(|(number, _)| number)
537
- .unwrap_or(&problem.id);
538
- format!(
539
- "# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}",
540
- localized(&problem.title, &lang),
541
- ui_text(&lang, "difficulty"),
542
- problem.difficulty,
543
- ui_text(&lang, "topics"),
544
- problem.topics.join(", "),
545
- localized(&problem.statement, &lang),
546
- ui_text(&lang, "input"),
547
- localized(&problem.input, &lang),
548
- ui_text(&lang, "output"),
549
- localized(&problem.output, &lang),
550
- ui_text(&lang, "examples"),
551
- examples
552
- )
553
- }
554
-
555
- pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
556
- let lang = normalize_ui_language(ui_language);
557
- let number = problem
558
- .id
559
- .split_once('-')
560
- .map(|(number, _)| number)
561
- .unwrap_or(&problem.id);
562
- let mut lines = vec![
563
- format!("{number}. {}", localized(&problem.title, &lang)),
564
- format!(
565
- "{}: {} {}: {}",
566
- ui_text(&lang, "difficulty"),
567
- problem.difficulty,
568
- ui_text(&lang, "topics"),
569
- problem.topics.join(", ")
570
- ),
571
- String::new(),
572
- localized(&problem.statement, &lang),
573
- ];
574
- push_tui_section(
575
- &mut lines,
576
- ui_text(&lang, "input"),
577
- &localized(&problem.input, &lang),
578
- );
579
- push_tui_section(
580
- &mut lines,
581
- ui_text(&lang, "output"),
582
- &localized(&problem.output, &lang),
583
- );
584
- lines.push(String::new());
585
- lines.push(ui_text(&lang, "examples").to_string());
586
- for (index, case) in problem.examples.iter().enumerate() {
587
- lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
588
- lines.push(format!(" {}:", ui_text(&lang, "input")));
589
- push_case_text(&mut lines, &case.input);
590
- lines.push(format!(" {}:", ui_text(&lang, "output")));
591
- push_case_text(&mut lines, &case.output);
592
- }
593
- lines.join("\n").trim_end().to_string()
594
- }
595
-
596
- fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
597
- lines.push(String::new());
598
- lines.push(title.to_string());
599
- for line in body.trim_end().lines() {
600
- lines.push(format!(" {line}"));
601
- }
602
- }
603
-
604
- fn push_case_text(lines: &mut Vec<String>, body: &str) {
605
- let body = body.trim_end();
606
- if body.is_empty() {
607
- lines.push(" <empty>".to_string());
608
- } else {
609
- for line in body.lines() {
610
- lines.push(format!(" {line}"));
611
- }
612
- }
613
- }
614
-
615
- pub fn fenced_text(value: &str) -> String {
616
- let mut body = value.to_string();
617
- if !body.ends_with('\n') {
618
- body.push('\n');
619
- }
620
- format!("```text\n{body}```")
621
- }
622
-
623
- pub fn judge(root: &Path, problem: &Problem, settings: &Settings) -> JudgeResult {
624
- if problem.cases.is_empty() {
625
- return JudgeResult {
626
- passed: false,
627
- passed_cases: 0,
628
- total_cases: 0,
629
- output: "problem has no judge cases".to_string(),
630
- };
631
- }
632
- let path = match ensure_submission(root, problem, settings) {
633
- Ok(path) => path,
634
- Err(error) => {
635
- return JudgeResult {
636
- passed: false,
637
- passed_cases: 0,
638
- total_cases: problem.cases.len(),
639
- output: error.to_string(),
640
- };
641
- }
642
- };
643
- let language = normalize_language(&settings.language);
644
- let command = match command_for(root, &path, &language) {
645
- Ok(Some(command)) => command,
646
- Ok(None) => {
647
- return JudgeResult {
648
- passed: false,
649
- passed_cases: 0,
650
- total_cases: problem.cases.len(),
651
- output: format!("Missing runtime for {}", settings.language),
652
- };
653
- }
654
- Err(error) => {
655
- return JudgeResult {
656
- passed: false,
657
- passed_cases: 0,
658
- total_cases: problem.cases.len(),
659
- output: format!("compile failed\n{error}"),
660
- };
661
- }
662
- };
663
- let run_dir = root.join(".practicode/build").join(&problem.id).join("run");
664
- if let Err(error) = fs::create_dir_all(&run_dir) {
665
- return JudgeResult {
666
- passed: false,
667
- passed_cases: 0,
668
- total_cases: problem.cases.len(),
669
- output: error.to_string(),
670
- };
671
- }
672
-
673
- let mut passed = 0;
674
- let mut lines = Vec::new();
675
- for (index, case) in problem.cases.iter().enumerate() {
676
- let mut process = Command::new(&command.program);
677
- process.args(&command.args).current_dir(&run_dir);
678
- let run = match run_capture(&mut process, &case.input, Duration::from_secs(5)) {
679
- Ok(run) => run,
680
- Err(error) => {
681
- lines.push(format!("Case {}: FAIL", index + 1));
682
- push_labeled_block(&mut lines, "Error", &error.to_string());
683
- break;
684
- }
685
- };
686
- let got = run.stdout.trim();
687
- let expected = case.output.trim();
688
- if !run.timed_out && run.code == Some(0) && got == expected {
689
- passed += 1;
690
- lines.push(format!("Case {}: PASS", index + 1));
691
- if !run.stderr.trim().is_empty() {
692
- push_labeled_block(&mut lines, "Stderr", run.stderr.trim_end());
693
- }
694
- } else {
695
- lines.push(format!("Case {}: FAIL", index + 1));
696
- if run.timed_out {
697
- push_labeled_block(&mut lines, "Error", "timeout: 5s");
698
- }
699
- push_labeled_block(&mut lines, "Input", case.input.trim_end());
700
- push_labeled_block(&mut lines, "Expected", expected);
701
- push_labeled_block(&mut lines, "Got", run.stdout.trim_end());
702
- if !run.stderr.trim().is_empty() {
703
- push_labeled_block(&mut lines, "Stderr", run.stderr.trim_end());
704
- }
705
- break;
706
- }
707
- }
708
-
709
- JudgeResult {
710
- passed: passed == problem.cases.len(),
711
- passed_cases: passed,
712
- total_cases: problem.cases.len(),
713
- output: lines.join("\n"),
714
- }
715
- }
716
-
717
- fn push_labeled_block(lines: &mut Vec<String>, label: &str, body: &str) {
718
- lines.push(String::new());
719
- lines.push(label.to_string());
720
- if body.is_empty() {
721
- lines.push(" <empty>".to_string());
722
- } else {
723
- lines.extend(body.lines().map(|line| format!(" {line}")));
724
- }
725
- }
726
-
727
- pub fn command_for(root: &Path, path: &Path, language: &str) -> Result<Option<CommandSpec>> {
728
- match language {
729
- "python" => Ok(which("python3")
730
- .or_else(|| which("python"))
731
- .map(|program| CommandSpec {
732
- program,
733
- args: vec![path.display().to_string()],
734
- })),
735
- "ts" => Ok(which("node").map(|program| CommandSpec {
736
- program,
737
- args: vec![
738
- "--experimental-strip-types".to_string(),
739
- path.display().to_string(),
740
- ],
741
- })),
742
- "java" => compile_java(root, path),
743
- "rust" => compile_rust(root, path),
744
- _ => Ok(None),
745
- }
746
- }
747
-
748
- fn compile_java(root: &Path, path: &Path) -> Result<Option<CommandSpec>> {
749
- let Some(javac) = which("javac") else {
750
- return Ok(None);
751
- };
752
- let Some(java) = which("java") else {
753
- return Ok(None);
754
- };
755
- let build = root
756
- .join(".practicode/build")
757
- .join(path.parent().and_then(Path::file_name).unwrap_or_default())
758
- .join("java");
759
- fs::create_dir_all(&build)?;
760
- let mut compile = Command::new(javac);
761
- compile
762
- .args([
763
- "-d",
764
- &build.display().to_string(),
765
- &path.display().to_string(),
766
- ])
767
- .current_dir(root);
768
- let output = run_capture(&mut compile, "", Duration::from_secs(30))?;
769
- if output.code != Some(0) {
770
- return Err(anyhow!(output.stderr.trim().to_string()));
771
- }
772
- Ok(Some(CommandSpec {
773
- program: java,
774
- args: vec![
775
- "-cp".to_string(),
776
- build.display().to_string(),
777
- "Solution".to_string(),
778
- ],
779
- }))
780
- }
781
-
782
- fn compile_rust(root: &Path, path: &Path) -> Result<Option<CommandSpec>> {
783
- let Some(rustc) = which("rustc") else {
784
- return Ok(None);
785
- };
786
- let build = root
787
- .join(".practicode/build")
788
- .join(path.parent().and_then(Path::file_name).unwrap_or_default());
789
- fs::create_dir_all(&build)?;
790
- let exe = build.join(if cfg!(windows) {
791
- "solution.exe"
792
- } else {
793
- "solution"
794
- });
795
- let mut compile = Command::new(rustc);
796
- compile
797
- .args([
798
- path.display().to_string(),
799
- "-o".to_string(),
800
- exe.display().to_string(),
801
- ])
802
- .current_dir(root);
803
- let output = run_capture(&mut compile, "", Duration::from_secs(30))?;
804
- if output.code != Some(0) {
805
- return Err(anyhow!(output.stderr.trim().to_string()));
806
- }
807
- Ok(Some(CommandSpec {
808
- program: exe,
809
- args: Vec::new(),
810
- }))
811
- }
812
-
813
- pub fn give_up(root: &Path, problem: &Problem, state: &mut AppState) -> Result<String> {
814
- let language = normalize_language(&state.settings.language);
815
- let answer = problem
816
- .answers
817
- .get(&language)
818
- .cloned()
819
- .unwrap_or_else(|| problem.answers.values().next().cloned().unwrap_or_default());
820
- mark_history(state, &problem.id, "gave_up");
821
- upsert_problem_index(root, problem, "gave_up")?;
822
- save_state(root, state)?;
823
- Ok(answer)
824
- }
825
-
826
- pub fn next_problem(
827
- root: &Path,
828
- bank: &[Problem],
829
- state: &mut AppState,
830
- ) -> Result<Option<Problem>> {
831
- let seen = state
832
- .history
833
- .iter()
834
- .map(|item| item.id.as_str())
835
- .collect::<Vec<_>>();
836
- let preferred = if state.settings.difficulty == "auto" {
837
- &state.suggested_next_difficulty
838
- } else {
839
- &state.settings.difficulty
840
- };
841
- let problem = bank
842
- .iter()
843
- .find(|item| !seen.contains(&item.id.as_str()) && &item.difficulty == preferred)
844
- .or_else(|| bank.iter().find(|item| !seen.contains(&item.id.as_str())));
845
- let Some(problem) = problem.cloned() else {
846
- return Ok(None);
847
- };
848
- state.current_problem = problem.id.clone();
849
- mark_history(state, &problem.id, "assigned");
850
- save_state(root, state)?;
851
- ensure_problem_files(root, &problem)?;
852
- upsert_problem_index(root, &problem, "assigned")?;
853
- Ok(Some(problem))
854
- }
855
-
856
- pub fn previous_problem(root: &Path, bank: &[Problem], state: &mut AppState) -> Result<Problem> {
857
- let known_ids = bank
858
- .iter()
859
- .map(|problem| problem.id.as_str())
860
- .collect::<Vec<_>>();
861
- let history = state
862
- .history
863
- .iter()
864
- .filter(|item| known_ids.contains(&item.id.as_str()))
865
- .map(|item| item.id.clone())
866
- .collect::<Vec<_>>();
867
- let Some(index) = history.iter().position(|id| id == &state.current_problem) else {
868
- return problem_by_id(bank, &state.current_problem)
869
- .cloned()
870
- .ok_or_else(|| anyhow!("current problem missing"));
871
- };
872
- if index == 0 {
873
- return problem_by_id(bank, &state.current_problem)
874
- .cloned()
875
- .ok_or_else(|| anyhow!("current problem missing"));
876
- }
877
- state.current_problem = history[index - 1].clone();
878
- save_state(root, state)?;
879
- problem_by_id(bank, &state.current_problem)
880
- .cloned()
881
- .ok_or_else(|| anyhow!("current problem missing"))
882
- }
883
-
884
- pub fn record_pass(root: &Path, problem: &Problem, state: &mut AppState) -> Result<()> {
885
- if !state.solved.contains(&problem.id) {
886
- state.solved.push(problem.id.clone());
887
- }
888
- mark_history(state, &problem.id, "solved");
889
- upsert_problem_index(root, problem, "solved")?;
890
- state.suggested_next_difficulty = if state.solved.len() >= 2 {
891
- "medium".to_string()
892
- } else {
893
- "easy".to_string()
894
- };
895
- save_state(root, state)
896
- }
897
-
898
- pub fn mark_history(state: &mut AppState, problem_id: &str, status: &str) {
899
- if let Some(item) = state.history.iter_mut().find(|item| item.id == problem_id) {
900
- item.status = status.to_string();
901
- } else {
902
- state.history.push(HistoryItem {
903
- id: problem_id.to_string(),
904
- status: status.to_string(),
905
- });
906
- }
907
- }
908
-
909
- pub fn ensure_problem_files(root: &Path, problem: &Problem) -> Result<()> {
910
- let problem_dir = root.join("problems").join(&problem.id);
911
- fs::create_dir_all(&problem_dir)?;
912
- let readme = problem_dir.join("README.md");
913
- if readme.exists() {
914
- return Ok(());
915
- }
916
- let examples = problem
917
- .examples
918
- .iter()
919
- .map(|case| format!("input:\n{}output:\n{}", case.input, case.output))
920
- .collect::<Vec<_>>()
921
- .join("\n");
922
- fs::write(
923
- readme,
924
- format!(
925
- "# {}. {}\n\n난이도: {}\n\n{}\n\n## 입력\n\n{}\n\n## 출력\n\n{}\n\n## 예시\n\n```text\n{}\n```\n",
926
- problem.id,
927
- localized(&problem.title, "ko"),
928
- problem.difficulty,
929
- localized(&problem.statement, "ko"),
930
- localized(&problem.input, "ko"),
931
- localized(&problem.output, "ko"),
932
- examples
933
- ),
934
- )?;
935
- Ok(())
936
- }
937
-
938
- pub fn upsert_problem_index(root: &Path, problem: &Problem, status: &str) -> Result<()> {
939
- let index = root.join("problems/INDEX.md");
940
- if let Some(parent) = index.parent() {
941
- fs::create_dir_all(parent)?;
942
- }
943
- let mut rows: HashMap<String, (String, String, String, String)> = HashMap::new();
944
- if index.exists() {
945
- for line in fs::read_to_string(&index)?.lines() {
946
- let parts = line
947
- .trim()
948
- .trim_matches('|')
949
- .split('|')
950
- .map(str::trim)
951
- .collect::<Vec<_>>();
952
- if parts.len() == 5 && parts[0].chars().all(|c| c.is_ascii_digit()) {
953
- rows.insert(
954
- parts[0].to_string(),
955
- (
956
- parts[1].to_string(),
957
- parts[2].to_string(),
958
- parts[3].to_string(),
959
- parts[4].to_string(),
960
- ),
961
- );
962
- }
963
- }
964
- }
965
- let number = problem
966
- .id
967
- .split_once('-')
968
- .map(|(number, _)| number)
969
- .unwrap_or(&problem.id);
970
- rows.insert(
971
- number.to_string(),
972
- (
973
- problem.slug.clone(),
974
- problem.difficulty.clone(),
975
- problem.topics.join(", "),
976
- status.to_string(),
977
- ),
978
- );
979
- let mut numbers = rows.keys().cloned().collect::<Vec<_>>();
980
- numbers.sort();
981
- let body = numbers
982
- .into_iter()
983
- .filter_map(|number| {
984
- rows.get(&number)
985
- .map(|(slug, difficulty, topics, row_status)| {
986
- format!("| {number} | {slug} | {difficulty} | {topics} | {row_status} |")
987
- })
988
- })
989
- .collect::<Vec<_>>()
990
- .join("\n");
991
- fs::write(
992
- index,
993
- format!(
994
- "# Problem Index\n\n| # | Slug | Difficulty | Topics | Status |\n|---|------|------------|--------|--------|\n{body}\n"
995
- ),
996
- )?;
997
- Ok(())
998
- }
31
+ pub use progress::*;
32
+ pub use render::*;
33
+ pub use state::*;