practicode 0.1.9 → 0.1.10
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 +1 -1
- package/Cargo.toml +1 -1
- package/docs/ARCHITECTURE.md +20 -4
- package/package.json +1 -1
- package/src/core/bank.rs +148 -0
- package/src/core/judge.rs +205 -0
- package/src/core/language.rs +92 -0
- package/src/core/model.rs +153 -0
- package/src/core/problem_files.rs +92 -0
- package/src/core/progress.rs +97 -0
- package/src/core/render.rs +119 -0
- package/src/core/state.rs +80 -0
- package/src/core.rs +18 -983
- package/src/tui/actions.rs +312 -0
- package/src/tui/command_handlers.rs +128 -0
- package/src/tui/command_input.rs +117 -0
- package/src/tui/events.rs +188 -0
- package/src/tui/problem_list.rs +163 -0
- package/src/tui/status.rs +106 -0
- package/src/tui/tasks.rs +279 -0
- package/src/tui/view.rs +342 -0
- package/src/tui.rs +8 -1607
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub fn ensure_problem_files(root: &Path, problem: &Problem) -> Result<()> {
|
|
4
|
+
let problem_dir = root.join("problems").join(&problem.id);
|
|
5
|
+
fs::create_dir_all(&problem_dir)?;
|
|
6
|
+
let readme = problem_dir.join("README.md");
|
|
7
|
+
if readme.exists() {
|
|
8
|
+
return Ok(());
|
|
9
|
+
}
|
|
10
|
+
let examples = problem
|
|
11
|
+
.examples
|
|
12
|
+
.iter()
|
|
13
|
+
.map(|case| format!("input:\n{}output:\n{}", case.input, case.output))
|
|
14
|
+
.collect::<Vec<_>>()
|
|
15
|
+
.join("\n");
|
|
16
|
+
fs::write(
|
|
17
|
+
readme,
|
|
18
|
+
format!(
|
|
19
|
+
"# {}. {}\n\n난이도: {}\n\n{}\n\n## 입력\n\n{}\n\n## 출력\n\n{}\n\n## 예시\n\n```text\n{}\n```\n",
|
|
20
|
+
problem.id,
|
|
21
|
+
localized(&problem.title, "ko"),
|
|
22
|
+
problem.difficulty,
|
|
23
|
+
localized(&problem.statement, "ko"),
|
|
24
|
+
localized(&problem.input, "ko"),
|
|
25
|
+
localized(&problem.output, "ko"),
|
|
26
|
+
examples
|
|
27
|
+
),
|
|
28
|
+
)?;
|
|
29
|
+
Ok(())
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
pub fn upsert_problem_index(root: &Path, problem: &Problem, status: &str) -> Result<()> {
|
|
33
|
+
let index = root.join("problems/INDEX.md");
|
|
34
|
+
if let Some(parent) = index.parent() {
|
|
35
|
+
fs::create_dir_all(parent)?;
|
|
36
|
+
}
|
|
37
|
+
let mut rows: HashMap<String, (String, String, String, String)> = HashMap::new();
|
|
38
|
+
if index.exists() {
|
|
39
|
+
for line in fs::read_to_string(&index)?.lines() {
|
|
40
|
+
let parts = line
|
|
41
|
+
.trim()
|
|
42
|
+
.trim_matches('|')
|
|
43
|
+
.split('|')
|
|
44
|
+
.map(str::trim)
|
|
45
|
+
.collect::<Vec<_>>();
|
|
46
|
+
if parts.len() == 5 && parts[0].chars().all(|c| c.is_ascii_digit()) {
|
|
47
|
+
rows.insert(
|
|
48
|
+
parts[0].to_string(),
|
|
49
|
+
(
|
|
50
|
+
parts[1].to_string(),
|
|
51
|
+
parts[2].to_string(),
|
|
52
|
+
parts[3].to_string(),
|
|
53
|
+
parts[4].to_string(),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
let number = problem
|
|
60
|
+
.id
|
|
61
|
+
.split_once('-')
|
|
62
|
+
.map(|(number, _)| number)
|
|
63
|
+
.unwrap_or(&problem.id);
|
|
64
|
+
rows.insert(
|
|
65
|
+
number.to_string(),
|
|
66
|
+
(
|
|
67
|
+
problem.slug.clone(),
|
|
68
|
+
problem.difficulty.clone(),
|
|
69
|
+
problem.topics.join(", "),
|
|
70
|
+
status.to_string(),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
let mut numbers = rows.keys().cloned().collect::<Vec<_>>();
|
|
74
|
+
numbers.sort();
|
|
75
|
+
let body = numbers
|
|
76
|
+
.into_iter()
|
|
77
|
+
.filter_map(|number| {
|
|
78
|
+
rows.get(&number)
|
|
79
|
+
.map(|(slug, difficulty, topics, row_status)| {
|
|
80
|
+
format!("| {number} | {slug} | {difficulty} | {topics} | {row_status} |")
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
.collect::<Vec<_>>()
|
|
84
|
+
.join("\n");
|
|
85
|
+
fs::write(
|
|
86
|
+
index,
|
|
87
|
+
format!(
|
|
88
|
+
"# Problem Index\n\n| # | Slug | Difficulty | Topics | Status |\n|---|------|------------|--------|--------|\n{body}\n"
|
|
89
|
+
),
|
|
90
|
+
)?;
|
|
91
|
+
Ok(())
|
|
92
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub fn give_up(root: &Path, problem: &Problem, state: &mut AppState) -> Result<String> {
|
|
4
|
+
let language = normalize_language(&state.settings.language);
|
|
5
|
+
let answer = problem
|
|
6
|
+
.answers
|
|
7
|
+
.get(&language)
|
|
8
|
+
.cloned()
|
|
9
|
+
.unwrap_or_else(|| problem.answers.values().next().cloned().unwrap_or_default());
|
|
10
|
+
mark_history(state, &problem.id, "gave_up");
|
|
11
|
+
upsert_problem_index(root, problem, "gave_up")?;
|
|
12
|
+
save_state(root, state)?;
|
|
13
|
+
Ok(answer)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
pub fn next_problem(
|
|
17
|
+
root: &Path,
|
|
18
|
+
bank: &[Problem],
|
|
19
|
+
state: &mut AppState,
|
|
20
|
+
) -> Result<Option<Problem>> {
|
|
21
|
+
let seen = state
|
|
22
|
+
.history
|
|
23
|
+
.iter()
|
|
24
|
+
.map(|item| item.id.as_str())
|
|
25
|
+
.collect::<Vec<_>>();
|
|
26
|
+
let preferred = if state.settings.difficulty == "auto" {
|
|
27
|
+
&state.suggested_next_difficulty
|
|
28
|
+
} else {
|
|
29
|
+
&state.settings.difficulty
|
|
30
|
+
};
|
|
31
|
+
let problem = bank
|
|
32
|
+
.iter()
|
|
33
|
+
.find(|item| !seen.contains(&item.id.as_str()) && &item.difficulty == preferred)
|
|
34
|
+
.or_else(|| bank.iter().find(|item| !seen.contains(&item.id.as_str())));
|
|
35
|
+
let Some(problem) = problem.cloned() else {
|
|
36
|
+
return Ok(None);
|
|
37
|
+
};
|
|
38
|
+
state.current_problem = problem.id.clone();
|
|
39
|
+
mark_history(state, &problem.id, "assigned");
|
|
40
|
+
save_state(root, state)?;
|
|
41
|
+
ensure_problem_files(root, &problem)?;
|
|
42
|
+
upsert_problem_index(root, &problem, "assigned")?;
|
|
43
|
+
Ok(Some(problem))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub fn previous_problem(root: &Path, bank: &[Problem], state: &mut AppState) -> Result<Problem> {
|
|
47
|
+
let known_ids = bank
|
|
48
|
+
.iter()
|
|
49
|
+
.map(|problem| problem.id.as_str())
|
|
50
|
+
.collect::<Vec<_>>();
|
|
51
|
+
let history = state
|
|
52
|
+
.history
|
|
53
|
+
.iter()
|
|
54
|
+
.filter(|item| known_ids.contains(&item.id.as_str()))
|
|
55
|
+
.map(|item| item.id.clone())
|
|
56
|
+
.collect::<Vec<_>>();
|
|
57
|
+
let Some(index) = history.iter().position(|id| id == &state.current_problem) else {
|
|
58
|
+
return problem_by_id(bank, &state.current_problem)
|
|
59
|
+
.cloned()
|
|
60
|
+
.ok_or_else(|| anyhow!("current problem missing"));
|
|
61
|
+
};
|
|
62
|
+
if index == 0 {
|
|
63
|
+
return problem_by_id(bank, &state.current_problem)
|
|
64
|
+
.cloned()
|
|
65
|
+
.ok_or_else(|| anyhow!("current problem missing"));
|
|
66
|
+
}
|
|
67
|
+
state.current_problem = history[index - 1].clone();
|
|
68
|
+
save_state(root, state)?;
|
|
69
|
+
problem_by_id(bank, &state.current_problem)
|
|
70
|
+
.cloned()
|
|
71
|
+
.ok_or_else(|| anyhow!("current problem missing"))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pub fn record_pass(root: &Path, problem: &Problem, state: &mut AppState) -> Result<()> {
|
|
75
|
+
if !state.solved.contains(&problem.id) {
|
|
76
|
+
state.solved.push(problem.id.clone());
|
|
77
|
+
}
|
|
78
|
+
mark_history(state, &problem.id, "solved");
|
|
79
|
+
upsert_problem_index(root, problem, "solved")?;
|
|
80
|
+
state.suggested_next_difficulty = if state.solved.len() >= 2 {
|
|
81
|
+
"medium".to_string()
|
|
82
|
+
} else {
|
|
83
|
+
"easy".to_string()
|
|
84
|
+
};
|
|
85
|
+
save_state(root, state)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
pub fn mark_history(state: &mut AppState, problem_id: &str, status: &str) {
|
|
89
|
+
if let Some(item) = state.history.iter_mut().find(|item| item.id == problem_id) {
|
|
90
|
+
item.status = status.to_string();
|
|
91
|
+
} else {
|
|
92
|
+
state.history.push(HistoryItem {
|
|
93
|
+
id: problem_id.to_string(),
|
|
94
|
+
status: status.to_string(),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub fn localized(map: &HashMap<String, String>, lang: &str) -> String {
|
|
4
|
+
let lang = normalize_ui_language(lang);
|
|
5
|
+
map.get(lang.as_str())
|
|
6
|
+
.or_else(|| map.get("en"))
|
|
7
|
+
.or_else(|| map.get("ko"))
|
|
8
|
+
.or_else(|| map.values().next())
|
|
9
|
+
.cloned()
|
|
10
|
+
.unwrap_or_default()
|
|
11
|
+
}
|
|
12
|
+
pub fn render_problem(problem: &Problem, ui_language: &str) -> String {
|
|
13
|
+
let lang = normalize_ui_language(ui_language);
|
|
14
|
+
let examples = problem
|
|
15
|
+
.examples
|
|
16
|
+
.iter()
|
|
17
|
+
.enumerate()
|
|
18
|
+
.map(|(index, case)| {
|
|
19
|
+
format!(
|
|
20
|
+
"### {} {}\n\n{}\n\n{}\n\n{}\n\n{}",
|
|
21
|
+
ui_text(&lang, "example"),
|
|
22
|
+
index + 1,
|
|
23
|
+
ui_text(&lang, "input"),
|
|
24
|
+
fenced_text(&case.input),
|
|
25
|
+
ui_text(&lang, "output"),
|
|
26
|
+
fenced_text(&case.output)
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
.collect::<Vec<_>>()
|
|
30
|
+
.join("\n\n");
|
|
31
|
+
let number = problem
|
|
32
|
+
.id
|
|
33
|
+
.split_once('-')
|
|
34
|
+
.map(|(number, _)| number)
|
|
35
|
+
.unwrap_or(&problem.id);
|
|
36
|
+
format!(
|
|
37
|
+
"# {number}. {}\n\n{}: {}\n{}: {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}\n\n## {}\n\n{}",
|
|
38
|
+
localized(&problem.title, &lang),
|
|
39
|
+
ui_text(&lang, "difficulty"),
|
|
40
|
+
problem.difficulty,
|
|
41
|
+
ui_text(&lang, "topics"),
|
|
42
|
+
problem.topics.join(", "),
|
|
43
|
+
localized(&problem.statement, &lang),
|
|
44
|
+
ui_text(&lang, "input"),
|
|
45
|
+
localized(&problem.input, &lang),
|
|
46
|
+
ui_text(&lang, "output"),
|
|
47
|
+
localized(&problem.output, &lang),
|
|
48
|
+
ui_text(&lang, "examples"),
|
|
49
|
+
examples
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
pub fn render_problem_tui(problem: &Problem, ui_language: &str) -> String {
|
|
54
|
+
let lang = normalize_ui_language(ui_language);
|
|
55
|
+
let number = problem
|
|
56
|
+
.id
|
|
57
|
+
.split_once('-')
|
|
58
|
+
.map(|(number, _)| number)
|
|
59
|
+
.unwrap_or(&problem.id);
|
|
60
|
+
let mut lines = vec![
|
|
61
|
+
format!("{number}. {}", localized(&problem.title, &lang)),
|
|
62
|
+
format!(
|
|
63
|
+
"{}: {} {}: {}",
|
|
64
|
+
ui_text(&lang, "difficulty"),
|
|
65
|
+
problem.difficulty,
|
|
66
|
+
ui_text(&lang, "topics"),
|
|
67
|
+
problem.topics.join(", ")
|
|
68
|
+
),
|
|
69
|
+
String::new(),
|
|
70
|
+
localized(&problem.statement, &lang),
|
|
71
|
+
];
|
|
72
|
+
push_tui_section(
|
|
73
|
+
&mut lines,
|
|
74
|
+
ui_text(&lang, "input"),
|
|
75
|
+
&localized(&problem.input, &lang),
|
|
76
|
+
);
|
|
77
|
+
push_tui_section(
|
|
78
|
+
&mut lines,
|
|
79
|
+
ui_text(&lang, "output"),
|
|
80
|
+
&localized(&problem.output, &lang),
|
|
81
|
+
);
|
|
82
|
+
lines.push(String::new());
|
|
83
|
+
lines.push(ui_text(&lang, "examples").to_string());
|
|
84
|
+
for (index, case) in problem.examples.iter().enumerate() {
|
|
85
|
+
lines.push(format!(" {} {}", ui_text(&lang, "example"), index + 1));
|
|
86
|
+
lines.push(format!(" {}:", ui_text(&lang, "input")));
|
|
87
|
+
push_case_text(&mut lines, &case.input);
|
|
88
|
+
lines.push(format!(" {}:", ui_text(&lang, "output")));
|
|
89
|
+
push_case_text(&mut lines, &case.output);
|
|
90
|
+
}
|
|
91
|
+
lines.join("\n").trim_end().to_string()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fn push_tui_section(lines: &mut Vec<String>, title: &str, body: &str) {
|
|
95
|
+
lines.push(String::new());
|
|
96
|
+
lines.push(title.to_string());
|
|
97
|
+
for line in body.trim_end().lines() {
|
|
98
|
+
lines.push(format!(" {line}"));
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
fn push_case_text(lines: &mut Vec<String>, body: &str) {
|
|
103
|
+
let body = body.trim_end();
|
|
104
|
+
if body.is_empty() {
|
|
105
|
+
lines.push(" <empty>".to_string());
|
|
106
|
+
} else {
|
|
107
|
+
for line in body.lines() {
|
|
108
|
+
lines.push(format!(" {line}"));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub fn fenced_text(value: &str) -> String {
|
|
114
|
+
let mut body = value.to_string();
|
|
115
|
+
if !body.ends_with('\n') {
|
|
116
|
+
body.push('\n');
|
|
117
|
+
}
|
|
118
|
+
format!("```text\n{body}```")
|
|
119
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub fn load_state(root: &Path, bank: &[Problem]) -> Result<AppState> {
|
|
4
|
+
let path = root.join(STATE_PATH);
|
|
5
|
+
if !path.exists() {
|
|
6
|
+
return Ok(AppState {
|
|
7
|
+
current_problem: bank[0].id.clone(),
|
|
8
|
+
settings: Settings::default(),
|
|
9
|
+
solved: Vec::new(),
|
|
10
|
+
history: vec![HistoryItem {
|
|
11
|
+
id: bank[0].id.clone(),
|
|
12
|
+
status: "assigned".to_string(),
|
|
13
|
+
}],
|
|
14
|
+
suggested_next_difficulty: "easy".to_string(),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
|
|
19
|
+
let mut state: AppState =
|
|
20
|
+
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
|
|
21
|
+
if !bank
|
|
22
|
+
.iter()
|
|
23
|
+
.any(|problem| problem.id == state.current_problem)
|
|
24
|
+
{
|
|
25
|
+
state.current_problem = bank[0].id.clone();
|
|
26
|
+
}
|
|
27
|
+
normalize_settings(&mut state.settings);
|
|
28
|
+
if state.history.is_empty() {
|
|
29
|
+
state.history.push(HistoryItem {
|
|
30
|
+
id: state.current_problem.clone(),
|
|
31
|
+
status: "assigned".to_string(),
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
Ok(state)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
pub fn save_state(root: &Path, state: &AppState) -> Result<()> {
|
|
38
|
+
#[derive(Serialize)]
|
|
39
|
+
struct StateFile<'a> {
|
|
40
|
+
current_problem: &'a str,
|
|
41
|
+
next_number: usize,
|
|
42
|
+
suggested_next_difficulty: &'a str,
|
|
43
|
+
settings: &'a Settings,
|
|
44
|
+
solved: &'a [String],
|
|
45
|
+
history: &'a [HistoryItem],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let path = root.join(STATE_PATH);
|
|
49
|
+
if let Some(parent) = path.parent() {
|
|
50
|
+
fs::create_dir_all(parent)?;
|
|
51
|
+
}
|
|
52
|
+
let file = StateFile {
|
|
53
|
+
current_problem: &state.current_problem,
|
|
54
|
+
next_number: state.history.len() + 1,
|
|
55
|
+
suggested_next_difficulty: &state.suggested_next_difficulty,
|
|
56
|
+
settings: &state.settings,
|
|
57
|
+
solved: &state.solved,
|
|
58
|
+
history: &state.history,
|
|
59
|
+
};
|
|
60
|
+
fs::write(path, serde_json::to_string_pretty(&file)? + "\n")?;
|
|
61
|
+
Ok(())
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
pub fn normalize_settings(settings: &mut Settings) {
|
|
65
|
+
settings.language = normalize_language(&settings.language);
|
|
66
|
+
settings.ui_language = normalize_ui_language(&settings.ui_language);
|
|
67
|
+
if !THEMES.contains(&settings.theme.as_str()) {
|
|
68
|
+
settings.theme = "dark".to_string();
|
|
69
|
+
}
|
|
70
|
+
settings.difficulty = normalize_difficulty(&settings.difficulty);
|
|
71
|
+
settings.topics = normalize_topic_list(&settings.topics);
|
|
72
|
+
settings.avoid_topics = normalize_topic_list(&settings.avoid_topics);
|
|
73
|
+
settings.generate_languages = normalize_language_list(&settings.generate_languages);
|
|
74
|
+
settings.generate_ui_languages = normalize_ui_language_list(&settings.generate_ui_languages);
|
|
75
|
+
settings.next_source = normalize_next_source(&settings.next_source);
|
|
76
|
+
settings.ai_provider = normalize_ai_provider(&settings.ai_provider);
|
|
77
|
+
if settings.ai_model.trim().is_empty() {
|
|
78
|
+
settings.ai_model = default_ai_model();
|
|
79
|
+
}
|
|
80
|
+
}
|