Skip to main content

arct_core/
lesson_run.rs

1//! Playing a lesson, with no user interface: the hint ladder ([`Coach`])
2//! and a whole lesson run in the simulated sandbox ([`LessonRun`]).
3//!
4//! The terminal app's lesson panel keeps its own step position and uses
5//! [`Coach`] for hints; the browser plays a lesson with [`LessonRun`],
6//! which does what the terminal app does when Enter is pressed in lesson
7//! mode: run the line in the sandbox (unless it answers a quiz), then
8//! check the step against what was typed, what it printed and the
9//! practice files.
10
11use crate::command::CommandAnalyzer;
12use crate::guard::{GuardVerdict, PlaygroundGuard};
13use crate::lesson::{
14    practice_checks, validate_step, Attempt, Check, Lesson, LessonStep, LessonValidator,
15    PracticeWorld, StepType, ValidationResult,
16};
17use crate::sandbox::{Marks, Sandbox, ShellState};
18use crate::shell_syntax::Redirect;
19use crate::virtual_fs::VirtualFileSystem;
20
21/// The hint ladder for one lesson run: each miss on a step reveals the
22/// next hint; the answer only after the last one, and never on a
23/// challenge step
24#[derive(Debug, Clone, Default)]
25pub struct Coach {
26    /// Wrong answers in this lesson run (for the "perfect_lesson" achievement)
27    wrong_answers: usize,
28    /// Misses on the current step: each one reveals the next hint rung
29    step_misses: usize,
30    /// The answer was shown at some point in this run
31    answer_revealed: bool,
32}
33
34impl Coach {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Validate a step against what was typed, what the command printed,
40    /// whether it failed, and the practice environment (for task steps).
41    /// A miss climbs the hint ladder.
42    pub fn check(
43        &mut self,
44        step: &LessonStep,
45        validator: &LessonValidator,
46        attempt: &Attempt,
47        command_failed: bool,
48        world: Option<&dyn PracticeWorld>,
49    ) -> ValidationResult {
50        let mut result = validate_step(step, validator, attempt, command_failed, world);
51
52        if !result.is_success() && is_look_around(step, attempt.input) {
53            // Looking isn't a wrong answer: say what's still missing, but
54            // don't climb the ladder or count it against the run
55            if let ValidationResult::Failure { message, .. } = result {
56                result = ValidationResult::Failure { message, hint: None };
57            }
58        } else if !result.is_success() {
59            self.wrong_answers += 1;
60            self.step_misses += 1;
61            if !step.challenge && self.step_misses > step.hint_ladder().len() {
62                self.answer_revealed = true;
63            }
64            result = coach(step, result, self.step_misses);
65        }
66        result
67    }
68
69    /// A new step: its hints start from the first rung
70    pub fn new_step(&mut self) {
71        self.step_misses = 0;
72    }
73
74    /// Wrong answers so far in this run
75    pub fn wrong_answers(&self) -> usize {
76        self.wrong_answers
77    }
78
79    /// The answer was shown at some point in this run
80    pub fn answer_revealed(&self) -> bool {
81        self.answer_revealed
82    }
83}
84
85/// Turn a raw failure into coaching: a hint per miss, the answer last
86fn coach(step: &LessonStep, raw: ValidationResult, misses: usize) -> ValidationResult {
87    let ValidationResult::Failure { message, hint } = raw else {
88        return raw;
89    };
90    // Task-style failures say what is missing without giving the answer
91    let outcome_based = matches!(
92        step.step_type,
93        StepType::Task { .. } | StepType::Practice { .. }
94    );
95    let gentle = if outcome_based { message.clone() } else { "Not quite.".to_string() };
96
97    if step.challenge {
98        return ValidationResult::Failure {
99            message: gentle,
100            hint: None,
101        };
102    }
103    let ladder = step.hint_ladder();
104    if misses <= ladder.len() {
105        ValidationResult::Failure {
106            message: gentle,
107            hint: Some(ladder[misses - 1].to_string()),
108        }
109    } else {
110        // Out of hints: show what the validator knows (the answer)
111        ValidationResult::Failure {
112            message,
113            hint: hint.or_else(|| ladder.last().map(|h| h.to_string())),
114        }
115    }
116}
117
118/// Programs that show where things are, never what's in them
119const FINDING: &[&str] = &[
120    "ls", "pwd", "cd", "tree", "find", "file", "stat", "du", "df", "which", "type", "whoami", "id",
121    "hostname", "uname", "date", "history", "clear", "help", "man",
122];
123
124/// Programs that show what's in files or the shell, and change nothing
125const READING: &[&str] = &[
126    "cat", "less", "more", "head", "tail", "grep", "wc", "sort", "uniq", "cut", "diff", "cmp", "echo",
127    "printf", "env", "printenv",
128];
129
130/// find options that write, delete or run something
131const FIND_ACTIONS: &[&str] = &["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"];
132
133/// The programs a line runs, in order; `None` when the line can't be read
134/// or does more than run programs (assignments, writing to a file)
135fn looking_programs(line: &str) -> Option<Vec<(String, Vec<String>)>> {
136    let list = crate::shell_syntax::parse(line, &|_| None).ok()?;
137    let mut programs = Vec::new();
138    for (_, pipeline) in list {
139        for stage in pipeline.stages {
140            if !stage.assignments.is_empty() {
141                return None;
142            }
143            let writes = stage.redirects.iter().any(|r| match r {
144                Redirect::Out { target, .. } | Redirect::Err { target, .. } => target != "/dev/null",
145                Redirect::ErrToOut | Redirect::In(_) => false,
146            });
147            if writes {
148                return None;
149            }
150            let mut words = stage.words.into_iter().map(|w| w.text);
151            let program = words.next()?;
152            programs.push((program, words.collect()));
153        }
154    }
155    Some(programs)
156}
157
158/// A line that only looks around (ls, cd, cat ...) on a step judged by
159/// its outcome, where that kind of looking can't be the answer. It
160/// doesn't count as a miss. Looking is the answer when the step checks
161/// for that program (`Used`) or its solution runs it, when it's `cd` and
162/// the step checks where you are, or when it reads and the step checks
163/// what a command shows.
164fn is_look_around(step: &LessonStep, line: &str) -> bool {
165    let checks = match &step.step_type {
166        StepType::Task { checks, .. } => checks.clone(),
167        StepType::Practice { validation, .. } => practice_checks(validation),
168        _ => return false,
169    };
170    let Some(programs) = looking_programs(line) else {
171        return false;
172    };
173    if programs.is_empty() {
174        return false;
175    }
176    let solution: Vec<String> = step
177        .solution
178        .iter()
179        .filter_map(|s| looking_programs(s))
180        .flatten()
181        .map(|(program, _)| program)
182        .collect();
183    let judges_output = checks.iter().any(|c| matches!(c, Check::OutputContains(_)));
184    let judges_cwd = checks.iter().any(|c| matches!(c, Check::CwdIs(_)));
185
186    programs.iter().all(|(program, args)| {
187        let program = program.as_str();
188        let checked = checks.iter().any(|c| matches!(c, Check::Used(p) if p == program))
189            || solution.iter().any(|p| p == program);
190        if checked {
191            return false;
192        }
193        if program == "find" && args.iter().any(|a| FIND_ACTIONS.contains(&a.as_str())) {
194            return false;
195        }
196        if program == "cd" {
197            return !judges_cwd;
198        }
199        if FINDING.contains(&program) {
200            return true;
201        }
202        READING.contains(&program) && !judges_output
203    })
204}
205
206/// What one submitted line did
207#[derive(Debug, Clone)]
208pub struct Turn {
209    /// The current folder when the line was typed, as a prompt shows it
210    /// (`~`, `~/station`)
211    pub prompt_cwd: String,
212    /// The line as typed
213    pub command: String,
214    /// What the command printed (nothing for a quiz answer)
215    pub output: String,
216    /// How the step check went; `None` when the line wasn't checked (it
217    /// couldn't be read as a command, or the lesson was already finished)
218    pub result: Option<ValidationResult>,
219    /// This line finished the lesson
220    pub finished: bool,
221}
222
223/// One lesson played in a sandbox of its own
224pub struct LessonRun {
225    lesson: Lesson,
226    step: usize,
227    done: bool,
228    validator: LessonValidator,
229    coach: Coach,
230    analyzer: CommandAnalyzer,
231    marks: Marks,
232    world: VirtualFileSystem,
233    shell: ShellState,
234}
235
236impl LessonRun {
237    /// Start `lesson` from its first step, with its practice files held in
238    /// memory
239    pub fn new(lesson: Lesson) -> anyhow::Result<Self> {
240        Self::with_world(lesson, VirtualFileSystem::for_lesson_in_memory)
241    }
242
243    /// Start `lesson` with practice files made by `make` (in memory, or on
244    /// disk for comparing the two)
245    pub fn with_world(
246        lesson: Lesson,
247        make: impl Fn(&Lesson) -> anyhow::Result<VirtualFileSystem>,
248    ) -> anyhow::Result<Self> {
249        let world = make(&lesson)?;
250        Ok(Self {
251            lesson,
252            step: 0,
253            done: false,
254            validator: LessonValidator::new(),
255            coach: Coach::new(),
256            analyzer: CommandAnalyzer::new(),
257            marks: Marks::plain(),
258            world,
259            shell: ShellState::new(),
260        })
261    }
262
263    /// Use these marks for messages from now on
264    pub fn set_marks(&mut self, marks: Marks) {
265        self.marks = marks;
266    }
267
268    pub fn lesson(&self) -> &Lesson {
269        &self.lesson
270    }
271
272    /// The current step (the last one once the lesson is finished)
273    pub fn step(&self) -> &LessonStep {
274        &self.lesson.steps[self.step.min(self.lesson.steps.len().saturating_sub(1))]
275    }
276
277    /// 0-based index of the current step
278    pub fn step_index(&self) -> usize {
279        self.step
280    }
281
282    /// Every step has passed
283    pub fn is_done(&self) -> bool {
284        self.done
285    }
286
287    /// The run never needed the answer (hints are fine)
288    pub fn mastered(&self) -> bool {
289        !self.coach.answer_revealed()
290    }
291
292    /// The practice files
293    pub fn world(&self) -> &VirtualFileSystem {
294        &self.world
295    }
296
297    /// The current folder as a prompt shows it: `~`, `~/station`
298    pub fn display_cwd(&self) -> String {
299        let rel = self.world.cwd_relative();
300        if rel.is_empty() {
301            "~".to_string()
302        } else {
303            format!("~/{rel}")
304        }
305    }
306
307    /// Run one typed line and check the step with it
308    pub fn submit(&mut self, line: &str) -> Turn {
309        let prompt_cwd = self.display_cwd();
310        let mut turn = Turn {
311            prompt_cwd,
312            command: line.to_string(),
313            output: String::new(),
314            result: None,
315            finished: false,
316        };
317        if self.done || self.lesson.steps.is_empty() {
318            return turn;
319        }
320        let step = self.step().clone();
321
322        let result = if line.is_empty() {
323            // Enter on its own: continues past information, nothing else
324            self.coach.check(&step, &self.validator, &Attempt { input: line, output: "" }, false, None)
325        } else {
326            if let Err(e) = self.analyzer.parse(line) {
327                turn.output = self.marks.error(e);
328                return turn;
329            }
330            // A quiz answer ("1") is not a command: don't run it
331            let answering = matches!(step.step_type, StepType::MultipleChoice { .. });
332            let output = if answering {
333                None
334            } else if let GuardVerdict::Refuse { reason } = PlaygroundGuard::check_catastrophic(line) {
335                Some(format!("{}Blocked. Nothing ran.\n\n{}\n", self.marks.warning, reason))
336            } else {
337                let mut sandbox = Sandbox::new(&mut self.shell, &mut self.world, &self.analyzer, &self.marks);
338                Some(sandbox.run_line(line))
339            };
340            let output = output.unwrap_or_default();
341            let failed = self.marks.reports_failure(&output);
342            let attempt = Attempt { input: line, output: &output };
343            let result = self.coach.check(&step, &self.validator, &attempt, failed, Some(&self.world));
344            turn.output = output;
345            result
346        };
347
348        if result.is_success() {
349            if self.step + 1 < self.lesson.steps.len() {
350                self.step += 1;
351                self.coach.new_step();
352            } else {
353                self.done = true;
354                turn.finished = true;
355            }
356        }
357        turn.result = Some(result);
358        turn
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn task(checks: Vec<Check>, solution: &[&str]) -> LessonStep {
367        LessonStep {
368            step_number: 1,
369            title: "t".into(),
370            instruction: String::new(),
371            hint: None,
372            step_type: StepType::Task {
373                goal: String::new(),
374                checks,
375                success_message: String::new(),
376            },
377            hints: vec![],
378            challenge: false,
379            solution: solution.iter().map(|s| s.to_string()).collect(),
380        }
381    }
382
383    #[test]
384    fn looking_around_a_world_task_is_not_an_answer() {
385        let step = task(vec![Check::FileExists("bakery.tar.gz".into())], &["tar -czf bakery.tar.gz bakery"]);
386        for line in [
387            "ls",
388            "ls -la bakery",
389            "pwd",
390            "cd bakery",
391            "cd ..",
392            "cat bakery/notes.txt",
393            "ls | wc -l",
394            "ls nope 2>/dev/null",
395            "cd bakery; ls; cd ..",
396            "find . -name '*.txt'",
397            "grep -r Oven bakery",
398        ] {
399            assert!(is_look_around(&step, line), "{line} only looks");
400        }
401        for line in [
402            "",
403            "tar -cf bakery.tar bakery",
404            "mkdir bakery.tar.gz",
405            "ls > list.txt",
406            "cat notes.txt >> bakery.tar.gz",
407            "ls 2> errors.txt",
408            "ls; touch x",
409            "ls | tee out.txt",
410            "find . -name '*.txt' -delete",
411            "find . -exec rm {} ;",
412            "X=1 ls",
413            "frobnicate",
414            "$(ls)",
415        ] {
416            assert!(!is_look_around(&step, line), "{line} is an attempt");
417        }
418    }
419
420    #[test]
421    fn looking_is_the_answer_when_the_step_judges_it() {
422        // Reading is how an output step is answered; listing still only looks
423        let read = task(vec![Check::OutputContains("Pip".into())], &["cat station/vents/.stowaway.txt"]);
424        assert!(!is_look_around(&read, "cat station/vents/stowaway.txt"));
425        assert!(!is_look_around(&read, "head station/vents/.stowaway.txt"));
426        assert!(is_look_around(&read, "ls -a station/vents"));
427        assert!(is_look_around(&read, "cd station"));
428
429        // The solution lists: a listing is an attempt
430        let list = task(vec![Check::OutputContains(".stowaway".into())], &["ls -a station/vents"]);
431        assert!(!is_look_around(&list, "ls station/vents"));
432
433        // Checked for by name
434        let used = task(vec![Check::Used("find".into()), Check::OutputContains("x".into())], &[]);
435        assert!(!is_look_around(&used, "find . -name x"));
436        assert!(is_look_around(&used, "ls"));
437
438        // Where you are is checked: cd is an attempt, ls still looks
439        let cwd = task(vec![Check::CwdIs("station".into())], &["cd station"]);
440        assert!(!is_look_around(&cwd, "cd statoin"));
441        let cwd_no_solution = task(vec![Check::CwdIs("station".into())], &[]);
442        assert!(!is_look_around(&cwd_no_solution, "cd statoin"));
443        assert!(is_look_around(&cwd_no_solution, "ls"));
444    }
445
446    #[test]
447    fn only_outcome_steps_have_look_arounds() {
448        let mut step = task(vec![], &[]);
449        step.step_type = StepType::CommandExercise {
450            expected_command: "pwd".into(),
451            validation: crate::lesson::CommandValidation::CommandOnly,
452            success_message: String::new(),
453        };
454        assert!(!is_look_around(&step, "ls"));
455    }
456}