Skip to main content

cli_tutor/
app.rs

1use crate::config::Config;
2use crate::content::types::{Difficulty, Exercise, ModuleFile};
3use crate::executor::{ExecutionResult, Executor};
4use crate::matcher::Matcher;
5use crate::progress::{ModuleProgress, Progress};
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum ContentView {
9    Intro,
10    Examples,
11    Exercise,
12    // free_practice.VIEW.1 — free-practice is the 4th view in the cycle
13    FreePractice,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum SubmitState {
18    Idle,
19    Correct,
20    Wrong,
21    Error,
22}
23
24// difficulty_filter.FILTER.1
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum DifficultyFilter {
27    None,
28    Beginner,
29    Intermediate,
30    Advanced,
31}
32
33impl std::fmt::Display for DifficultyFilter {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            DifficultyFilter::None => write!(f, "All"),
37            DifficultyFilter::Beginner => write!(f, "Beginner"),
38            DifficultyFilter::Intermediate => write!(f, "Intermediate"),
39            DifficultyFilter::Advanced => write!(f, "Advanced"),
40        }
41    }
42}
43
44pub struct App {
45    pub modules: Vec<ModuleFile>,
46    pub selected_module: usize,
47    pub current_view: ContentView,
48    pub current_exercise: usize,
49
50    pub input: String,
51    pub cursor_pos: usize,
52
53    pub last_output: Option<ExecutionResult>,
54    pub submit_state: SubmitState,
55
56    pub hints_revealed: usize,
57    pub show_solution: bool,
58    pub show_files: bool,
59    pub show_help: bool,
60
61    pub intro_scroll: u16,
62    pub examples_scroll: u16,
63    pub output_scroll: u16,
64
65    pub progress: Progress,
66    pub should_quit: bool,
67
68    // config_file.CONFIG.3
69    pub config: Config,
70
71    // command_history.HISTORY.1
72    pub command_history: Vec<String>,
73    pub history_idx: Option<usize>,
74    history_draft: String,
75
76    // module_search.SEARCH.1
77    pub search_active: bool,
78    pub search_query: String,
79    pub search_filtered: Vec<usize>,
80
81    // progress_summary.OVERLAY.1
82    pub show_progress: bool,
83
84    // difficulty_filter.FILTER.1
85    pub difficulty_filter: DifficultyFilter,
86
87    // timed_challenge.TIMER.1
88    pub timer_start: Option<std::time::Instant>,
89    pub last_solve_ms: Option<u64>,
90}
91
92impl App {
93    pub fn new(modules: Vec<ModuleFile>, config: Config) -> Self {
94        let progress = Progress::load();
95
96        // config_file.CONFIG.2 — jump to default_module if configured
97        let selected_module = config
98            .default_module
99            .as_deref()
100            .and_then(|name| modules.iter().position(|m| m.module.name == name))
101            .unwrap_or(0);
102
103        App {
104            modules,
105            selected_module,
106            current_view: ContentView::Intro,
107            current_exercise: 0,
108            input: String::new(),
109            cursor_pos: 0,
110            last_output: None,
111            submit_state: SubmitState::Idle,
112            hints_revealed: 0,
113            show_solution: false,
114            show_files: false,
115            show_help: false,
116            intro_scroll: 0,
117            examples_scroll: 0,
118            output_scroll: 0,
119            progress,
120            should_quit: false,
121            config,
122            command_history: Vec::new(),
123            history_idx: None,
124            history_draft: String::new(),
125            search_active: false,
126            search_query: String::new(),
127            search_filtered: Vec::new(),
128            show_progress: false,
129            difficulty_filter: DifficultyFilter::None,
130            timer_start: None,
131            last_solve_ms: None,
132        }
133    }
134
135    pub fn current_module(&self) -> &ModuleFile {
136        &self.modules[self.selected_module]
137    }
138
139    pub fn current_exercise_opt(&self) -> Option<&Exercise> {
140        self.current_module().exercises.get(self.current_exercise)
141    }
142
143    pub fn exercise_count(&self) -> usize {
144        self.current_module().exercises.len()
145    }
146
147    pub fn module_progress(&self) -> Option<&ModuleProgress> {
148        let name = &self.current_module().module.name;
149        self.progress.modules.get(name)
150    }
151
152    pub fn exercise_is_completed(&self) -> bool {
153        if let Some(ex) = self.current_exercise_opt() {
154            let module_name = &self.current_module().module.name;
155            self.progress.is_completed(module_name, &ex.id)
156        } else {
157            false
158        }
159    }
160
161    // module_search.SEARCH.3 — visible module indices (filtered or all)
162    pub fn visible_module_indices(&self) -> Vec<usize> {
163        if self.search_active && !self.search_query.is_empty() {
164            self.search_filtered.clone()
165        } else {
166            (0..self.modules.len()).collect()
167        }
168    }
169
170    // --- Input handling ---
171
172    pub fn input_push(&mut self, c: char) {
173        // timed_challenge.TIMER.1 — start timer on first keystroke in exercise
174        if self.config.timed_challenge
175            && self.current_view == ContentView::Exercise
176            && self.timer_start.is_none()
177        {
178            self.timer_start = Some(std::time::Instant::now());
179        }
180        self.input.insert(self.cursor_pos, c);
181        self.cursor_pos += c.len_utf8();
182        // command_history.HISTORY.4 — typing cancels history navigation
183        self.history_idx = None;
184    }
185
186    pub fn input_backspace(&mut self) {
187        if self.cursor_pos > 0 {
188            let prev = self.input[..self.cursor_pos]
189                .char_indices()
190                .last()
191                .map(|(i, _)| i)
192                .unwrap_or(0);
193            self.input.remove(prev);
194            self.cursor_pos = prev;
195        }
196        self.history_idx = None;
197    }
198
199    pub fn input_delete(&mut self) {
200        if self.cursor_pos < self.input.len() {
201            self.input.remove(self.cursor_pos);
202        }
203        self.history_idx = None;
204    }
205
206    // paste_support.INPUT.2 — insert pasted text, skip control chars
207    pub fn input_paste(&mut self, s: &str) {
208        for c in s.chars() {
209            if !c.is_control() {
210                self.input.insert(self.cursor_pos, c);
211                self.cursor_pos += c.len_utf8();
212            }
213        }
214        self.history_idx = None;
215    }
216
217    pub fn cursor_left(&mut self) {
218        if self.cursor_pos > 0 {
219            self.cursor_pos = self.input[..self.cursor_pos]
220                .char_indices()
221                .last()
222                .map(|(i, _)| i)
223                .unwrap_or(0);
224        }
225    }
226
227    pub fn cursor_right(&mut self) {
228        if self.cursor_pos < self.input.len() {
229            let mut chars = self.input[self.cursor_pos..].char_indices();
230            if let Some((_, c)) = chars.next() {
231                self.cursor_pos += c.len_utf8();
232            }
233        }
234    }
235
236    // word_jump.CURSOR.1 — jump to start of previous word
237    pub fn cursor_word_left(&mut self) {
238        let mut pos = self.cursor_pos;
239        // skip non-word chars going left
240        while pos > 0 {
241            let c = self.input[..pos].chars().last().unwrap();
242            if c.is_alphanumeric() || c == '_' {
243                break;
244            }
245            pos -= c.len_utf8();
246        }
247        // skip word chars going left
248        while pos > 0 {
249            let c = self.input[..pos].chars().last().unwrap();
250            if !c.is_alphanumeric() && c != '_' {
251                break;
252            }
253            pos -= c.len_utf8();
254        }
255        self.cursor_pos = pos;
256    }
257
258    // word_jump.CURSOR.2 — jump past end of next word
259    pub fn cursor_word_right(&mut self) {
260        let mut pos = self.cursor_pos;
261        let len = self.input.len();
262        // skip non-word chars going right
263        while pos < len {
264            let c = self.input[pos..].chars().next().unwrap();
265            if c.is_alphanumeric() || c == '_' {
266                break;
267            }
268            pos += c.len_utf8();
269        }
270        // skip word chars going right
271        while pos < len {
272            let c = self.input[pos..].chars().next().unwrap();
273            if !c.is_alphanumeric() && c != '_' {
274                break;
275            }
276            pos += c.len_utf8();
277        }
278        self.cursor_pos = pos;
279    }
280
281    pub fn cursor_home(&mut self) {
282        self.cursor_pos = 0;
283    }
284
285    pub fn cursor_end(&mut self) {
286        self.cursor_pos = self.input.len();
287    }
288
289    pub fn clear_input(&mut self) {
290        self.input.clear();
291        self.cursor_pos = 0;
292        self.history_idx = None;
293    }
294
295    // --- Command history ---
296
297    // command_history.HISTORY.2 — push on submit, no consecutive duplicates
298    fn push_history(&mut self, cmd: &str) {
299        let cmd = cmd.trim().to_string();
300        if cmd.is_empty() {
301            return;
302        }
303        if self.command_history.last().map(|s| s == &cmd).unwrap_or(false) {
304            return;
305        }
306        self.command_history.push(cmd);
307        self.history_idx = None;
308        self.history_draft.clear();
309    }
310
311    // command_history.HISTORY.3 — Up: load previous command
312    pub fn history_prev(&mut self) {
313        if self.command_history.is_empty() {
314            return;
315        }
316        match self.history_idx {
317            None => {
318                self.history_draft = self.input.clone();
319                self.history_idx = Some(self.command_history.len() - 1);
320            }
321            Some(0) => return,
322            Some(i) => {
323                self.history_idx = Some(i - 1);
324            }
325        }
326        if let Some(i) = self.history_idx {
327            self.input = self.command_history[i].clone();
328            self.cursor_pos = self.input.len();
329        }
330    }
331
332    // command_history.HISTORY.3 — Down: move toward present / restore draft
333    pub fn history_next(&mut self) {
334        match self.history_idx {
335            None => {}
336            Some(i) if i + 1 >= self.command_history.len() => {
337                self.history_idx = None;
338                self.input = self.history_draft.clone();
339                self.cursor_pos = self.input.len();
340            }
341            Some(i) => {
342                self.history_idx = Some(i + 1);
343                self.input = self.command_history[i + 1].clone();
344                self.cursor_pos = self.input.len();
345            }
346        }
347    }
348
349    // --- Module search ---
350
351    // module_search.SEARCH.1 — activate search mode
352    pub fn activate_search(&mut self) {
353        self.search_active = true;
354        self.search_query.clear();
355        self.search_filtered = (0..self.modules.len()).collect();
356    }
357
358    // module_search.SEARCH.2 — append char and refilter
359    pub fn search_push(&mut self, c: char) {
360        self.search_query.push(c);
361        self.update_search_filtered();
362        if let Some(&first) = self.search_filtered.first() {
363            self.selected_module = first;
364        }
365    }
366
367    pub fn search_backspace(&mut self) {
368        self.search_query.pop();
369        self.update_search_filtered();
370        if let Some(&first) = self.search_filtered.first() {
371            self.selected_module = first;
372        }
373    }
374
375    // module_search.SEARCH.4 — confirm selects first match
376    pub fn search_confirm(&mut self) {
377        if let Some(&first) = self.search_filtered.first() {
378            self.selected_module = first;
379            self.reset_content_state();
380        }
381        self.search_active = false;
382        self.search_query.clear();
383    }
384
385    // module_search.SEARCH.5 — Esc cancels search
386    pub fn search_cancel(&mut self) {
387        self.search_active = false;
388        self.search_query.clear();
389        self.search_filtered.clear();
390    }
391
392    fn update_search_filtered(&mut self) {
393        let q = self.search_query.to_lowercase();
394        self.search_filtered = self
395            .modules
396            .iter()
397            .enumerate()
398            .filter(|(_, m)| m.module.name.to_lowercase().contains(&q))
399            .map(|(i, _)| i)
400            .collect();
401    }
402
403    // --- Navigation ---
404
405    pub fn select_prev_module(&mut self) {
406        if self.selected_module > 0 {
407            self.selected_module -= 1;
408            self.reset_content_state();
409        }
410    }
411
412    pub fn select_next_module(&mut self) {
413        if self.selected_module + 1 < self.modules.len() {
414            self.selected_module += 1;
415            self.reset_content_state();
416        }
417    }
418
419    // free_practice.VIEW.1 — 4-step cycle including FreePractice
420    pub fn cycle_view(&mut self) {
421        self.current_view = match self.current_view {
422            ContentView::Intro => ContentView::Examples,
423            ContentView::Examples => ContentView::Exercise,
424            ContentView::Exercise => ContentView::FreePractice,
425            ContentView::FreePractice => ContentView::Intro,
426        };
427    }
428
429    // difficulty_filter.FILTER.2 — cycle filter states
430    pub fn cycle_difficulty_filter(&mut self) {
431        self.difficulty_filter = match self.difficulty_filter {
432            DifficultyFilter::None => DifficultyFilter::Beginner,
433            DifficultyFilter::Beginner => DifficultyFilter::Intermediate,
434            DifficultyFilter::Intermediate => DifficultyFilter::Advanced,
435            DifficultyFilter::Advanced => DifficultyFilter::None,
436        };
437        // difficulty_filter.FILTER.4 — jump to first matching exercise on filter change
438        if self.current_view == ContentView::Exercise {
439            self.jump_to_first_matching_exercise();
440        }
441    }
442
443    // difficulty_filter.FILTER.3 — skip non-matching in forward navigation
444    pub fn next_exercise(&mut self) {
445        let count = self.exercise_count();
446        if count == 0 {
447            return;
448        }
449        let mut candidate = self.current_exercise;
450        loop {
451            if candidate + 1 >= count {
452                break;
453            }
454            candidate += 1;
455            if self.exercise_matches_filter(candidate) {
456                self.current_exercise = candidate;
457                self.reset_exercise_state();
458                return;
459            }
460        }
461    }
462
463    pub fn prev_exercise(&mut self) {
464        if self.current_exercise == 0 {
465            return;
466        }
467        let mut candidate = self.current_exercise;
468        loop {
469            if candidate == 0 {
470                break;
471            }
472            candidate -= 1;
473            if self.exercise_matches_filter(candidate) {
474                self.current_exercise = candidate;
475                self.reset_exercise_state();
476                return;
477            }
478        }
479    }
480
481    fn exercise_matches_filter(&self, idx: usize) -> bool {
482        let ex = match self.current_module().exercises.get(idx) {
483            Some(e) => e,
484            None => return false,
485        };
486        // config_file.CONFIG.7 — skip completed exercises when skip_completed is set
487        if self.config.skip_completed {
488            let module_name = &self.current_module().module.name;
489            if self.progress.is_completed(module_name, &ex.id) {
490                return false;
491            }
492        }
493        match self.difficulty_filter {
494            DifficultyFilter::None => true,
495            DifficultyFilter::Beginner => ex.difficulty == Difficulty::Beginner,
496            DifficultyFilter::Intermediate => ex.difficulty == Difficulty::Intermediate,
497            DifficultyFilter::Advanced => ex.difficulty == Difficulty::Advanced,
498        }
499    }
500
501    fn jump_to_first_matching_exercise(&mut self) {
502        let count = self.exercise_count();
503        for i in 0..count {
504            if self.exercise_matches_filter(i) {
505                self.current_exercise = i;
506                return;
507            }
508        }
509    }
510
511    // --- Exercise actions ---
512
513    pub fn submit_command(&mut self) {
514        let command = self.input.clone();
515        if command.trim().is_empty() {
516            return;
517        }
518
519        // command_history.HISTORY.2
520        self.push_history(&command);
521
522        let exercise = match self.current_exercise_opt() {
523            Some(ex) => ex.clone(),
524            None => return,
525        };
526
527        let result = Executor::run(&command, &exercise.fixtures);
528        let (exec_result, exec_error) = match result {
529            Ok(o) => (Some(o), false),
530            Err(e) => (
531                Some(ExecutionResult {
532                    stdout: String::new(),
533                    stderr: e.to_string(),
534                    timed_out: false,
535                }),
536                true,
537            ),
538        };
539
540        let correct = exec_result
541            .as_ref()
542            .map(|o| {
543                !exec_error
544                    && Matcher::check(&o.stdout, &exercise.expected_output, &exercise.match_mode)
545            })
546            .unwrap_or(false);
547
548        self.last_output = exec_result;
549        self.output_scroll = 0;
550
551        let module_name = self.current_module().module.name.clone();
552        if exec_error {
553            self.submit_state = SubmitState::Error;
554        } else if correct {
555            self.submit_state = SubmitState::Correct;
556            self.progress.mark_completed(&module_name, &exercise.id);
557
558            // timed_challenge.TIMER.2 — stop timer and record best time
559            if self.config.timed_challenge {
560                if let Some(start) = self.timer_start.take() {
561                    let ms = start.elapsed().as_millis() as u64;
562                    self.last_solve_ms = Some(ms);
563                    self.progress.record_time(&module_name, &exercise.id, ms);
564                }
565            }
566
567            let _ = self.progress.save();
568        } else {
569            self.submit_state = SubmitState::Wrong;
570            self.progress.mark_attempted(&module_name, &exercise.id);
571        }
572    }
573
574    // free_practice.VIEW.2 — run command with no expected-output matching
575    pub fn submit_command_free(&mut self) {
576        let command = self.input.clone();
577        if command.trim().is_empty() {
578            return;
579        }
580        self.push_history(&command);
581
582        let result = Executor::run(&command, &[]);
583        self.last_output = match result {
584            Ok(o) => Some(o),
585            Err(e) => Some(ExecutionResult {
586                stdout: String::new(),
587                stderr: e.to_string(),
588                timed_out: false,
589            }),
590        };
591        self.submit_state = SubmitState::Idle;
592        self.output_scroll = 0;
593    }
594
595    pub fn reveal_next_hint(&mut self) {
596        if let Some(ex) = self.current_exercise_opt() {
597            if self.hints_revealed < ex.hints.len() {
598                self.hints_revealed += 1;
599            }
600        }
601    }
602
603    pub fn toggle_solution(&mut self) {
604        self.show_solution = !self.show_solution;
605    }
606
607    pub fn toggle_files(&mut self) {
608        self.show_files = !self.show_files;
609    }
610
611    pub fn toggle_help(&mut self) {
612        self.show_help = !self.show_help;
613    }
614
615    // progress_summary.OVERLAY.4 — toggle progress overlay
616    pub fn toggle_progress(&mut self) {
617        self.show_progress = !self.show_progress;
618    }
619
620    pub fn reset_exercise(&mut self) {
621        self.reset_exercise_state();
622    }
623
624    pub fn clear_output(&mut self) {
625        self.last_output = None;
626        self.submit_state = SubmitState::Idle;
627        self.output_scroll = 0;
628    }
629
630    // --- Scrolling ---
631
632    pub fn scroll_up(&mut self) {
633        match self.current_view {
634            ContentView::Intro => self.intro_scroll = self.intro_scroll.saturating_sub(1),
635            ContentView::Examples => {
636                self.examples_scroll = self.examples_scroll.saturating_sub(1)
637            }
638            ContentView::Exercise | ContentView::FreePractice => {
639                self.output_scroll = self.output_scroll.saturating_sub(1)
640            }
641        }
642    }
643
644    pub fn scroll_down(&mut self) {
645        match self.current_view {
646            ContentView::Intro => self.intro_scroll += 1,
647            ContentView::Examples => self.examples_scroll += 1,
648            ContentView::Exercise | ContentView::FreePractice => self.output_scroll += 1,
649        }
650    }
651
652    // --- Helpers ---
653
654    fn reset_content_state(&mut self) {
655        self.current_view = ContentView::Intro;
656        self.current_exercise = 0;
657        self.reset_exercise_state();
658        self.intro_scroll = 0;
659        self.examples_scroll = 0;
660        // difficulty_filter.FILTER.4 — reset filter on module switch
661        self.difficulty_filter = DifficultyFilter::None;
662    }
663
664    fn reset_exercise_state(&mut self) {
665        self.clear_input();
666        self.last_output = None;
667        self.submit_state = SubmitState::Idle;
668        self.hints_revealed = 0;
669        self.show_solution = false;
670        self.show_files = false;
671        self.output_scroll = 0;
672        // timed_challenge.TIMER.1 — reset timer between exercises
673        self.timer_start = None;
674        self.last_solve_ms = None;
675    }
676}