practicode 0.1.8 → 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/README.md +5 -0
- package/SECURITY.md +11 -0
- package/docs/ARCHITECTURE.md +20 -4
- package/docs/MAINTAINING.md +5 -0
- package/package.json +7 -2
- 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,153 @@
|
|
|
1
|
+
use super::*;
|
|
2
|
+
|
|
3
|
+
pub const LANGUAGES: &[&str] = &["python", "ts", "java", "rust"];
|
|
4
|
+
pub const THEMES: &[&str] = &["dark", "light"];
|
|
5
|
+
pub const AI_PROVIDERS: &[&str] = &["codex", "claude"];
|
|
6
|
+
pub const BANK_PATH: &str = ".practicode/problem_bank.json";
|
|
7
|
+
pub const STATE_PATH: &str = ".practicode/problem-state.json";
|
|
8
|
+
pub const PROBLEM_NOTES_PATH: &str = ".practicode/problem_notes.md";
|
|
9
|
+
|
|
10
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
11
|
+
pub struct Settings {
|
|
12
|
+
#[serde(default = "default_language")]
|
|
13
|
+
pub language: String,
|
|
14
|
+
#[serde(default = "default_ui_language")]
|
|
15
|
+
pub ui_language: String,
|
|
16
|
+
#[serde(default = "default_theme")]
|
|
17
|
+
pub theme: String,
|
|
18
|
+
#[serde(default = "default_difficulty")]
|
|
19
|
+
pub difficulty: String,
|
|
20
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
21
|
+
pub topics: Vec<String>,
|
|
22
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
23
|
+
pub avoid_topics: Vec<String>,
|
|
24
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
25
|
+
pub generate_languages: Vec<String>,
|
|
26
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
27
|
+
pub generate_ui_languages: Vec<String>,
|
|
28
|
+
#[serde(default = "default_editor")]
|
|
29
|
+
pub editor: String,
|
|
30
|
+
#[serde(default = "default_next_source")]
|
|
31
|
+
pub next_source: String,
|
|
32
|
+
#[serde(default = "default_ai_provider")]
|
|
33
|
+
pub ai_provider: String,
|
|
34
|
+
#[serde(default = "default_ai_model")]
|
|
35
|
+
pub ai_model: String,
|
|
36
|
+
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
37
|
+
pub ai_next_command: String,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
impl Default for Settings {
|
|
41
|
+
fn default() -> Self {
|
|
42
|
+
Self {
|
|
43
|
+
language: default_language(),
|
|
44
|
+
ui_language: default_ui_language(),
|
|
45
|
+
theme: default_theme(),
|
|
46
|
+
difficulty: default_difficulty(),
|
|
47
|
+
topics: Vec::new(),
|
|
48
|
+
avoid_topics: Vec::new(),
|
|
49
|
+
generate_languages: Vec::new(),
|
|
50
|
+
generate_ui_languages: Vec::new(),
|
|
51
|
+
editor: default_editor(),
|
|
52
|
+
next_source: default_next_source(),
|
|
53
|
+
ai_provider: default_ai_provider(),
|
|
54
|
+
ai_model: default_ai_model(),
|
|
55
|
+
ai_next_command: String::new(),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
impl Settings {
|
|
61
|
+
pub fn next_ai_command(&self) -> &str {
|
|
62
|
+
&self.ai_next_command
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pub fn model_arg(&self) -> Option<&str> {
|
|
66
|
+
let model = self.ai_model.trim();
|
|
67
|
+
if model.is_empty() || model == "auto" {
|
|
68
|
+
None
|
|
69
|
+
} else {
|
|
70
|
+
Some(model)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
76
|
+
pub struct HistoryItem {
|
|
77
|
+
pub id: String,
|
|
78
|
+
pub status: String,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
82
|
+
pub struct AppState {
|
|
83
|
+
pub current_problem: String,
|
|
84
|
+
#[serde(default)]
|
|
85
|
+
pub settings: Settings,
|
|
86
|
+
#[serde(default)]
|
|
87
|
+
pub solved: Vec<String>,
|
|
88
|
+
#[serde(default)]
|
|
89
|
+
pub history: Vec<HistoryItem>,
|
|
90
|
+
#[serde(default = "default_suggested_difficulty")]
|
|
91
|
+
pub suggested_next_difficulty: String,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
95
|
+
pub struct Problem {
|
|
96
|
+
pub id: String,
|
|
97
|
+
pub slug: String,
|
|
98
|
+
pub difficulty: String,
|
|
99
|
+
pub topics: Vec<String>,
|
|
100
|
+
pub title: HashMap<String, String>,
|
|
101
|
+
pub statement: HashMap<String, String>,
|
|
102
|
+
pub input: HashMap<String, String>,
|
|
103
|
+
pub output: HashMap<String, String>,
|
|
104
|
+
pub examples: Vec<IoCase>,
|
|
105
|
+
pub cases: Vec<IoCase>,
|
|
106
|
+
pub answers: HashMap<String, String>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
110
|
+
pub struct IoCase {
|
|
111
|
+
pub input: String,
|
|
112
|
+
pub output: String,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#[derive(Clone, Debug)]
|
|
116
|
+
pub struct JudgeResult {
|
|
117
|
+
pub passed: bool,
|
|
118
|
+
pub passed_cases: usize,
|
|
119
|
+
pub total_cases: usize,
|
|
120
|
+
pub output: String,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
pub fn default_language() -> String {
|
|
124
|
+
"python".to_string()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
pub fn default_ui_language() -> String {
|
|
128
|
+
"en".to_string()
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pub fn default_theme() -> String {
|
|
132
|
+
"dark".to_string()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
pub fn default_editor() -> String {
|
|
136
|
+
"vim".to_string()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
pub fn default_next_source() -> String {
|
|
140
|
+
"bank".to_string()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pub fn default_ai_provider() -> String {
|
|
144
|
+
"codex".to_string()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
pub fn default_ai_model() -> String {
|
|
148
|
+
"auto".to_string()
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
pub fn default_suggested_difficulty() -> String {
|
|
152
|
+
"easy".to_string()
|
|
153
|
+
}
|
|
@@ -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
|
+
}
|