1use 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#[derive(Debug, Clone, Default)]
25pub struct Coach {
26 wrong_answers: usize,
28 step_misses: usize,
30 answer_revealed: bool,
32}
33
34impl Coach {
35 pub fn new() -> Self {
36 Self::default()
37 }
38
39 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 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 pub fn new_step(&mut self) {
71 self.step_misses = 0;
72 }
73
74 pub fn wrong_answers(&self) -> usize {
76 self.wrong_answers
77 }
78
79 pub fn answer_revealed(&self) -> bool {
81 self.answer_revealed
82 }
83}
84
85fn coach(step: &LessonStep, raw: ValidationResult, misses: usize) -> ValidationResult {
87 let ValidationResult::Failure { message, hint } = raw else {
88 return raw;
89 };
90 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 ValidationResult::Failure {
112 message,
113 hint: hint.or_else(|| ladder.last().map(|h| h.to_string())),
114 }
115 }
116}
117
118const 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
124const READING: &[&str] = &[
126 "cat", "less", "more", "head", "tail", "grep", "wc", "sort", "uniq", "cut", "diff", "cmp", "echo",
127 "printf", "env", "printenv",
128];
129
130const FIND_ACTIONS: &[&str] = &["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint", "-fprint0", "-fprintf", "-fls"];
132
133fn 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
158fn 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#[derive(Debug, Clone)]
208pub struct Turn {
209 pub prompt_cwd: String,
212 pub command: String,
214 pub output: String,
216 pub result: Option<ValidationResult>,
219 pub finished: bool,
221}
222
223pub 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 pub fn new(lesson: Lesson) -> anyhow::Result<Self> {
240 Self::with_world(lesson, VirtualFileSystem::for_lesson_in_memory)
241 }
242
243 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 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 pub fn step(&self) -> &LessonStep {
274 &self.lesson.steps[self.step.min(self.lesson.steps.len().saturating_sub(1))]
275 }
276
277 pub fn step_index(&self) -> usize {
279 self.step
280 }
281
282 pub fn is_done(&self) -> bool {
284 self.done
285 }
286
287 pub fn mastered(&self) -> bool {
289 !self.coach.answer_revealed()
290 }
291
292 pub fn world(&self) -> &VirtualFileSystem {
294 &self.world
295 }
296
297 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 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 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 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 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 let list = task(vec![Check::OutputContains(".stowaway".into())], &["ls -a station/vents"]);
431 assert!(!is_look_around(&list, "ls station/vents"));
432
433 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 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}