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.
@@ -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
+ }