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