Skip to main content

arct_core/
lesson.rs

1//! Interactive lesson system for teaching Linux/Bash concepts
2//!
3//! This module provides a comprehensive framework for creating, validating,
4//! and tracking progress through interactive lessons.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9/// A complete lesson module (e.g., "Navigation Basics")
10///
11/// # TOML lesson-pack format
12///
13/// Lessons can be authored as `*.toml` files (loaded from
14/// `~/.config/arct/lessons/` via [`LessonLibrary::load_from_dir`]). The
15/// top-level fields map 1:1 onto this struct:
16///
17/// ```toml
18/// id = "my-lesson"
19/// title = "My Lesson"
20/// description = "What this lesson teaches."
21/// difficulty = "Beginner"          # Beginner | Intermediate | Advanced | Expert
22/// estimated_minutes = 10
23/// prerequisites = ["nav-basics"]   # lesson ids to complete first
24/// tags = ["beginner", "files"]
25///
26/// # Optional starter files, materialized into the practice environment
27/// # before the lesson starts (omit entirely if the lesson needs none).
28/// # In simulated mode they are seeded into the virtual sandbox filesystem;
29/// # in real practice mode they are written under
30/// # ~/ArcAcademy/playground/<lesson-id>/ and the session cd's there.
31/// # `path` is relative to the lesson's practice directory (subdirectories
32/// # are created as needed; absolute paths and `..` are rejected).
33/// [[setup]]
34/// path = "notes.txt"
35/// contents = "starter content\n"
36///
37/// [[setup]]
38/// path = "logs/server.log"
39/// contents = "line 1\nline 2\n"
40///
41/// [[steps]]
42/// step_number = 1
43/// title = "First step"
44/// instruction = "Type the command..."
45/// hint = "Try 'pwd'"
46///
47/// [steps.step_type.CommandExercise]
48/// expected_command = "pwd"
49/// validation = "CommandOnly"
50/// success_message = "Nice!"
51/// ```
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct Lesson {
54    pub id: String,
55    pub title: String,
56    pub description: String,
57    pub difficulty: Difficulty,
58    pub estimated_minutes: u32,
59    pub steps: Vec<LessonStep>,
60    pub prerequisites: Vec<String>, // IDs of lessons that should be completed first
61    pub tags: Vec<String>,          // e.g., ["beginner", "navigation", "essential"]
62    /// Optional starter files for the lesson's practice environment.
63    ///
64    /// Defaults to empty, so existing lesson-pack TOML files without a
65    /// `setup` array keep parsing unchanged.
66    #[serde(default)]
67    pub setup: Vec<SetupFile>,
68    /// Which learning track the lesson belongs to. Defaults to Operator so
69    /// existing lesson packs keep their place.
70    #[serde(default)]
71    pub track: Track,
72    /// Runs in the simulated sandbox even when real-files practice is on:
73    /// real files run with no network, so a lesson whose commands reach
74    /// other computers (ping, curl) can't pass there
75    #[serde(default)]
76    pub simulated_only: bool,
77}
78
79/// Learning track: Explorer is written for younger learners (story-led,
80/// short steps, a hint ladder); Operator is the deeper adult path.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
82pub enum Track {
83    Explorer,
84    #[default]
85    Operator,
86}
87
88/// A starter file materialized into a lesson's practice environment.
89///
90/// In TOML lesson packs this is an entry in the `[[setup]]` array with a
91/// relative `path` and the literal file `contents`.
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SetupFile {
94    /// Path relative to the lesson's practice directory (no `..`, not absolute).
95    pub path: String,
96    /// Full file contents to write.
97    pub contents: String,
98}
99
100impl Lesson {
101    /// Serialize this lesson to a TOML document (the on-disk lesson format).
102    pub fn to_toml(&self) -> anyhow::Result<String> {
103        Ok(toml::to_string_pretty(self)?)
104    }
105
106    /// Parse a lesson from a TOML document.
107    pub fn from_toml(content: &str) -> anyhow::Result<Self> {
108        Ok(toml::from_str(content)?)
109    }
110}
111
112/// Difficulty level of a lesson
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub enum Difficulty {
115    Beginner,
116    Intermediate,
117    Advanced,
118    Expert,
119}
120
121/// A single step within a lesson
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct LessonStep {
124    pub step_number: u32,
125    pub title: String,
126    pub instruction: String,
127    /// Single hint (older lessons). Shown after the first miss.
128    pub hint: Option<String>,
129    pub step_type: StepType,
130    /// Hint ladder: one more rung is revealed after each wrong attempt.
131    /// After the last rung the answer is shown (never on challenge steps).
132    #[serde(default)]
133    pub hints: Vec<String>,
134    /// A challenge step gives no hints and never reveals the answer.
135    #[serde(default)]
136    pub challenge: bool,
137    /// Commands that complete this step, in order. Not shown to learners;
138    /// the test suite plays every built-in lesson with them. Needed for
139    /// task steps; other steps fall back to their expected answer.
140    #[serde(default)]
141    pub solution: Vec<String>,
142}
143
144impl LessonStep {
145    /// The hint ladder for this step (falls back to the single `hint`)
146    pub fn hint_ladder(&self) -> Vec<&str> {
147        if !self.hints.is_empty() {
148            self.hints.iter().map(String::as_str).collect()
149        } else {
150            self.hint.iter().map(String::as_str).collect()
151        }
152    }
153}
154
155/// Type of lesson step
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub enum StepType {
158    /// User must execute a specific command
159    CommandExercise {
160        expected_command: String,
161        validation: CommandValidation,
162        success_message: String,
163    },
164    /// Multiple choice question
165    MultipleChoice {
166        question: String,
167        options: Vec<String>,
168        correct_index: usize,
169        explanation: String,
170    },
171    /// Fill in the blank in a command
172    FillInBlank {
173        template: String,       // e.g., "ls {flags} /home"
174        correct_answers: Vec<String>, // e.g., ["-la", "-l -a"]
175        explanation: String,
176    },
177    /// Information/explanation only (no validation)
178    Information {
179        content: String,
180    },
181    /// Free-form practice with validation
182    Practice {
183        goal: String,
184        validation: PracticeValidation,
185        hints: Vec<String>,
186    },
187    /// Reach a goal with any commands: passes when every check holds in the
188    /// practice environment, however the learner got there
189    Task {
190        goal: String,
191        checks: Vec<Check>,
192        success_message: String,
193    },
194}
195
196/// One condition on the practice environment. Paths are relative to the
197/// lesson's home directory (where the lesson starts).
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub enum Check {
200    /// A directory exists
201    DirExists(String),
202    /// A file exists
203    FileExists(String),
204    /// Nothing exists at this path
205    Missing(String),
206    /// A file exists and contains the text
207    FileContains { path: String, text: String },
208    /// The current directory is this path ("" is the lesson home)
209    CwdIs(String),
210    /// The last command's output contains the text
211    OutputContains(String),
212    /// The last command ran this program (e.g. "grep")
213    Used(String),
214}
215
216/// The practice environment a lesson runs in, as seen by outcome checks
217pub trait PracticeWorld {
218    /// Real path for a path relative to the lesson home, if it stays inside
219    fn locate(&self, lesson_path: &str) -> Option<std::path::PathBuf>;
220    /// Current directory relative to the lesson home ("" at the home)
221    fn cwd_relative(&self) -> String;
222    /// What is at a path relative to the lesson home; `None` when the path
223    /// would leave it. By default, the real file [`PracticeWorld::locate`]
224    /// gives.
225    fn kind_at(&self, lesson_path: &str) -> Option<EntryKind> {
226        let path = self.locate(lesson_path)?;
227        Some(if path.is_dir() {
228            EntryKind::Dir
229        } else if path.is_file() {
230            EntryKind::File
231        } else if path.exists() {
232            EntryKind::Other
233        } else {
234            EntryKind::Missing
235        })
236    }
237    /// A text file's contents, by a path relative to the lesson home. By
238    /// default, read from the real file [`PracticeWorld::locate`] gives.
239    fn read_text(&self, lesson_path: &str) -> Option<String> {
240        std::fs::read_to_string(self.locate(lesson_path)?).ok()
241    }
242}
243
244/// What a path in the practice environment holds
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum EntryKind {
247    Missing,
248    File,
249    Dir,
250    /// Something that is neither (a device, a socket)
251    Other,
252}
253
254/// What the last command did, for outcome checks
255pub struct Attempt<'a> {
256    pub input: &'a str,
257    pub output: &'a str,
258}
259
260/// Evaluate checks in order; the first one that fails explains what's
261/// still missing, in words a learner can act on
262pub fn evaluate_checks(
263    checks: &[Check],
264    world: Option<&dyn PracticeWorld>,
265    attempt: &Attempt,
266) -> Result<(), String> {
267    let trim = |p: &str| p.trim_matches('/').to_string();
268    for check in checks {
269        let ok = match check {
270            Check::DirExists(path) => world.and_then(|w| w.kind_at(path)) == Some(EntryKind::Dir),
271            Check::FileExists(path) => world.and_then(|w| w.kind_at(path)) == Some(EntryKind::File),
272            Check::Missing(path) => world.and_then(|w| w.kind_at(path)) == Some(EntryKind::Missing),
273            Check::FileContains { path, text } => world
274                .and_then(|w| w.read_text(path))
275                .is_some_and(|c| c.contains(text.as_str())),
276            Check::CwdIs(path) => world.is_some_and(|w| trim(&w.cwd_relative()) == trim(path)),
277            Check::OutputContains(text) => attempt.output.contains(text.as_str()),
278            Check::Used(program) => attempt
279                .input
280                .split(|c| c == '|' || c == ';' || c == '&')
281                .any(|seg| seg.split_whitespace().next() == Some(program.as_str())),
282        };
283        if !ok {
284            return Err(match check {
285                Check::DirExists(p) => format!("Not yet: there's no folder called '{p}'."),
286                Check::FileExists(p) => format!("Not yet: there's no file called '{p}'."),
287                Check::Missing(p) => format!("Not yet: '{p}' is still there."),
288                Check::FileContains { path, text } => {
289                    format!("Not yet: '{path}' doesn't say \"{text}\" yet.")
290                }
291                Check::CwdIs(p) if p.is_empty() => "Not yet: go back to where you started.".to_string(),
292                Check::CwdIs(p) => format!("Not yet: you need to be inside '{p}'."),
293                Check::OutputContains(_) => "Not yet: that didn't show what we're looking for.".to_string(),
294                Check::Used(program) => format!("Not yet: this one needs '{program}'."),
295            });
296        }
297    }
298    Ok(())
299}
300
301/// Map the older practice validations onto checks
302pub(crate) fn practice_checks(validation: &PracticeValidation) -> Vec<Check> {
303    match validation {
304        PracticeValidation::FileExists(p) => vec![Check::FileExists(p.clone())],
305        PracticeValidation::DirectoryExists(p) => vec![Check::DirExists(p.clone())],
306        PracticeValidation::FileContains { path, content } => vec![Check::FileContains {
307            path: path.clone(),
308            text: content.clone(),
309        }],
310        PracticeValidation::OutputMatches { pattern, .. } => {
311            vec![Check::OutputContains(pattern.clone())]
312        }
313        PracticeValidation::Custom(_) => Vec::new(),
314    }
315}
316
317/// Validate any step against what the learner typed and what happened.
318///
319/// `command_failed` is true when the command reported an error; a command
320/// exercise only passes if the right command also worked.
321pub fn validate_step(
322    step: &LessonStep,
323    validator: &LessonValidator,
324    attempt: &Attempt,
325    command_failed: bool,
326    world: Option<&dyn PracticeWorld>,
327) -> ValidationResult {
328    match &step.step_type {
329        StepType::CommandExercise {
330            expected_command,
331            validation,
332            success_message,
333        } => {
334            let result = validator.validate_command(attempt.input, expected_command, validation);
335            if !result.is_success() {
336                result
337            } else if command_failed {
338                ValidationResult::Failure {
339                    message: "That's the right command, but it didn't work here. Read the error, then try again."
340                        .to_string(),
341                    hint: Some("Check the spelling of names and where you are (pwd).".to_string()),
342                }
343            } else {
344                ValidationResult::Success {
345                    message: success_message.clone(),
346                }
347            }
348        }
349        // Answers are numbered from 1 on screen (correct_index counts from 0)
350        StepType::MultipleChoice { correct_index, .. } => match attempt.input.trim().parse::<usize>() {
351            Ok(choice) if choice >= 1 => validator.validate_multiple_choice(choice - 1, *correct_index),
352            _ => ValidationResult::Failure {
353                message: "Type the number of your answer.".to_string(),
354                hint: None,
355            },
356        },
357        StepType::FillInBlank {
358            template,
359            correct_answers,
360            explanation,
361        } => {
362            let norm = |t: &str| t.split_whitespace().collect::<Vec<_>>().join(" ");
363            let input = norm(attempt.input);
364            let blank = template
365                .find('{')
366                .and_then(|a| template[a..].find('}').map(|b| (a, a + b + 1)));
367            let accepted = correct_answers.iter().any(|answer| {
368                let full = match blank {
369                    Some((a, b)) => format!("{}{}{}", &template[..a], answer, &template[b..]),
370                    None => answer.clone(),
371                };
372                input == norm(&full) || input == norm(answer)
373            });
374            if accepted {
375                ValidationResult::Success {
376                    message: explanation.clone(),
377                }
378            } else {
379                ValidationResult::Failure {
380                    message: "Not quite.".to_string(),
381                    hint: correct_answers.first().map(|a| format!("Expected: {a}")),
382                }
383            }
384        }
385        StepType::Information { .. } => ValidationResult::Success {
386            message: "Continue to next step.".to_string(),
387        },
388        StepType::Practice { validation, .. } => {
389            match evaluate_checks(&practice_checks(validation), world, attempt) {
390                Ok(()) => ValidationResult::Success {
391                    message: "You did it!".to_string(),
392                },
393                Err(message) => ValidationResult::Failure { message, hint: None },
394            }
395        }
396        StepType::Task {
397            checks,
398            success_message,
399            ..
400        } => match evaluate_checks(checks, world, attempt) {
401            Ok(()) => ValidationResult::Success {
402                message: success_message.clone(),
403            },
404            Err(message) => ValidationResult::Failure { message, hint: None },
405        },
406    }
407}
408
409/// How to validate a command exercise
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub enum CommandValidation {
412    /// Exact command match (ignoring whitespace)
413    Exact,
414    /// Command and flags must match (args can vary)
415    CommandAndFlags,
416    /// Just the base command must match
417    CommandOnly,
418    /// Custom validation with regex
419    Regex(String),
420    /// Multiple acceptable commands
421    AnyOf(Vec<String>),
422}
423
424/// Validation for practice exercises
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub enum PracticeValidation {
427    /// File exists at path
428    FileExists(String),
429    /// Directory exists at path
430    DirectoryExists(String),
431    /// File contains specific content
432    FileContains { path: String, content: String },
433    /// Command output matches pattern
434    OutputMatches { command: String, pattern: String },
435    /// Custom validator (description only, actual validation in engine)
436    Custom(String),
437}
438
439/// Progress tracking for a user through lessons
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct LessonProgress {
442    pub lesson_id: String,
443    pub current_step: u32,
444    pub completed_steps: Vec<u32>,
445    pub started_at: chrono::DateTime<chrono::Utc>,
446    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
447    pub attempts: HashMap<u32, u32>, // step_number -> attempt_count
448}
449
450impl LessonProgress {
451    pub fn new(lesson_id: String) -> Self {
452        Self {
453            lesson_id,
454            current_step: 1,
455            completed_steps: Vec::new(),
456            started_at: chrono::Utc::now(),
457            completed_at: None,
458            attempts: HashMap::new(),
459        }
460    }
461
462    pub fn is_completed(&self) -> bool {
463        self.completed_at.is_some()
464    }
465
466    pub fn completion_percentage(&self, total_steps: u32) -> f32 {
467        if total_steps == 0 {
468            return 0.0;
469        }
470        (self.completed_steps.len() as f32 / total_steps as f32) * 100.0
471    }
472
473    pub fn record_attempt(&mut self, step_number: u32) {
474        *self.attempts.entry(step_number).or_insert(0) += 1;
475    }
476
477    pub fn complete_step(&mut self, step_number: u32) {
478        if !self.completed_steps.contains(&step_number) {
479            self.completed_steps.push(step_number);
480            self.completed_steps.sort();
481        }
482    }
483
484    pub fn complete_lesson(&mut self) {
485        self.completed_at = Some(chrono::Utc::now());
486    }
487}
488
489/// Lesson library that stores all available lessons
490#[derive(Debug, Clone)]
491pub struct LessonLibrary {
492    lessons: HashMap<String, Lesson>,
493    /// Registration order; `all()` derives a stable learning order from it
494    order: Vec<String>,
495}
496
497impl LessonLibrary {
498    pub fn new() -> Self {
499        let mut library = Self {
500            lessons: HashMap::new(),
501            order: Vec::new(),
502        };
503        library.load_default_lessons();
504        library
505    }
506
507    /// Register a lesson in the library
508    pub fn register(&mut self, lesson: Lesson) {
509        if !self.lessons.contains_key(&lesson.id) {
510            self.order.push(lesson.id.clone());
511        }
512        self.lessons.insert(lesson.id.clone(), lesson);
513    }
514
515    /// Get a lesson by ID
516    pub fn get(&self, id: &str) -> Option<&Lesson> {
517        self.lessons.get(id)
518    }
519
520    /// All lessons in learning order: registration order, except that a
521    /// lesson always comes after the prerequisites it names. Stable across
522    /// runs (the map used to come out in random order).
523    pub fn all(&self) -> Vec<&Lesson> {
524        let mut placed: std::collections::HashSet<&str> = std::collections::HashSet::new();
525        let mut ordered: Vec<&Lesson> = Vec::with_capacity(self.order.len());
526        while ordered.len() < self.order.len() {
527            let before = ordered.len();
528            for id in &self.order {
529                if placed.contains(id.as_str()) {
530                    continue;
531                }
532                let lesson = &self.lessons[id];
533                let ready = lesson
534                    .prerequisites
535                    .iter()
536                    .all(|p| placed.contains(p.as_str()) || !self.lessons.contains_key(p));
537                if ready {
538                    placed.insert(id.as_str());
539                    ordered.push(lesson);
540                    // Restart so earlier-registered lessons that just became
541                    // ready keep their place ahead of later ones
542                    break;
543                }
544            }
545            if ordered.len() == before {
546                // Prerequisite cycle: append the rest in registration order
547                for id in &self.order {
548                    if placed.insert(id.as_str()) {
549                        ordered.push(&self.lessons[id]);
550                    }
551                }
552            }
553        }
554        ordered
555    }
556
557    /// Operator-track lessons at this difficulty: the ladder the difficulty
558    /// badges measure (Explorer has its own badge)
559    pub fn by_difficulty(&self, difficulty: Difficulty) -> Vec<&Lesson> {
560        self.all()
561            .into_iter()
562            .filter(|l| l.difficulty == difficulty && l.track == Track::Operator)
563            .collect()
564    }
565
566    /// Get lessons by tag
567    pub fn by_tag(&self, tag: &str) -> Vec<&Lesson> {
568        self.all()
569            .into_iter()
570            .filter(|l| l.tags.iter().any(|t| t == tag))
571            .collect()
572    }
573
574    /// Count lessons per difficulty level (used for achievement unlock checks)
575    pub fn difficulty_counts(&self) -> HashMap<Difficulty, usize> {
576        let mut counts = HashMap::new();
577        // The difficulty badges belong to the Operator path; Explorer has
578        // its own badge for finishing the track
579        for lesson in self.lessons.values().filter(|l| l.track == Track::Operator) {
580            *counts.entry(lesson.difficulty).or_insert(0) += 1;
581        }
582        counts
583    }
584
585    /// Load user-provided `*.toml` lesson files from a directory and merge
586    /// them with the built-in lessons. User lessons override built-ins with
587    /// the same id. Returns the number of lessons loaded.
588    ///
589    /// A missing directory is not an error (returns 0); an unreadable or
590    /// unparsable lesson file is.
591    pub fn load_from_dir(&mut self, path: &std::path::Path) -> anyhow::Result<usize> {
592        use anyhow::Context;
593
594        if !path.is_dir() {
595            return Ok(0);
596        }
597
598        let mut loaded = 0;
599        for entry in std::fs::read_dir(path)
600            .with_context(|| format!("Failed to read lesson directory: {}", path.display()))?
601        {
602            let entry = entry?;
603            let file_path = entry.path();
604            if file_path.extension().and_then(|e| e.to_str()) != Some("toml") {
605                continue;
606            }
607
608            let content = std::fs::read_to_string(&file_path)
609                .with_context(|| format!("Failed to read lesson file: {}", file_path.display()))?;
610            let lesson = Lesson::from_toml(&content)
611                .with_context(|| format!("Failed to parse lesson file: {}", file_path.display()))?;
612
613            self.register(lesson);
614            loaded += 1;
615        }
616
617        Ok(loaded)
618    }
619
620    /// Load default lessons (initial set)
621    fn load_default_lessons(&mut self) {
622        for (name, source) in BUILTIN_LESSONS {
623            let lesson = Lesson::from_toml(source)
624                .unwrap_or_else(|e| panic!("built-in lesson {name} is invalid: {e}"));
625            self.register(lesson);
626        }
627    }
628}
629
630/// Built-in lessons, in registration order. Each one is a TOML file under
631/// `crates/arct-core/lessons/`, the same format as user lesson packs, so a
632/// new lesson is a new file, not new Rust.
633const BUILTIN_LESSONS: &[(&str, &str)] = &[
634    ("explorer/look-around.toml", include_str!("../lessons/explorer/look-around.toml")),
635    ("explorer/walk-the-halls.toml", include_str!("../lessons/explorer/walk-the-halls.toml")),
636    ("explorer/read-the-logs.toml", include_str!("../lessons/explorer/read-the-logs.toml")),
637    ("explorer/hidden-compartments.toml", include_str!("../lessons/explorer/hidden-compartments.toml")),
638    ("explorer/build-your-bunk.toml", include_str!("../lessons/explorer/build-your-bunk.toml")),
639    ("explorer/move-the-cargo.toml", include_str!("../lessons/explorer/move-the-cargo.toml")),
640    ("explorer/careful-with-rm.toml", include_str!("../lessons/explorer/careful-with-rm.toml")),
641    ("explorer/search-the-station.toml", include_str!("../lessons/explorer/search-the-station.toml")),
642    ("explorer/write-in-the-log.toml", include_str!("../lessons/explorer/write-in-the-log.toml")),
643    ("explorer/first-script.toml", include_str!("../lessons/explorer/first-script.toml")),
644    ("operator/nav-basics.toml", include_str!("../lessons/operator/nav-basics.toml")),
645    ("operator/file-mgmt.toml", include_str!("../lessons/operator/file-mgmt.toml")),
646    ("operator/safety-essentials.toml", include_str!("../lessons/operator/safety-essentials.toml")),
647    ("operator/file-viewing.toml", include_str!("../lessons/operator/file-viewing.toml")),
648    ("operator/permissions.toml", include_str!("../lessons/operator/permissions.toml")),
649    ("operator/process-mgmt.toml", include_str!("../lessons/operator/process-mgmt.toml")),
650    ("operator/text-processing.toml", include_str!("../lessons/operator/text-processing.toml")),
651    ("operator/package-mgmt.toml", include_str!("../lessons/operator/package-mgmt.toml")),
652    ("operator/network-basics.toml", include_str!("../lessons/operator/network-basics.toml")),
653    ("operator/git-fundamentals.toml", include_str!("../lessons/operator/git-fundamentals.toml")),
654    ("operator/dotfiles.toml", include_str!("../lessons/operator/dotfiles.toml")),
655    ("operator/wildcards.toml", include_str!("../lessons/operator/wildcards.toml")),
656    ("operator/quoting.toml", include_str!("../lessons/operator/quoting.toml")),
657    ("operator/command-lists.toml", include_str!("../lessons/operator/command-lists.toml")),
658    ("operator/redirection.toml", include_str!("../lessons/operator/redirection.toml")),
659    ("operator/pipelines.toml", include_str!("../lessons/operator/pipelines.toml")),
660    ("operator/find-files.toml", include_str!("../lessons/operator/find-files.toml")),
661    ("operator/sed-basics.toml", include_str!("../lessons/operator/sed-basics.toml")),
662    ("operator/tr-and-cut.toml", include_str!("../lessons/operator/tr-and-cut.toml")),
663    ("operator/compare-files.toml", include_str!("../lessons/operator/compare-files.toml")),
664    ("operator/variables.toml", include_str!("../lessons/operator/variables.toml")),
665    ("operator/environment.toml", include_str!("../lessons/operator/environment.toml")),
666    ("operator/first-scripts.toml", include_str!("../lessons/operator/first-scripts.toml")),
667    ("operator/script-arguments.toml", include_str!("../lessons/operator/script-arguments.toml")),
668    ("operator/path-and-which.toml", include_str!("../lessons/operator/path-and-which.toml")),
669    ("operator/archives.toml", include_str!("../lessons/operator/archives.toml")),
670    ("operator/disk-usage.toml", include_str!("../lessons/operator/disk-usage.toml")),
671    ("operator/ssh-keys.toml", include_str!("../lessons/operator/ssh-keys.toml")),
672    ("operator/log-detective.toml", include_str!("../lessons/operator/log-detective.toml")),
673    ("operator/spring-cleaning.toml", include_str!("../lessons/operator/spring-cleaning.toml")),
674];
675
676impl Default for LessonLibrary {
677    fn default() -> Self {
678        Self::new()
679    }
680}
681
682/// Lesson validation engine
683pub struct LessonValidator {
684    // For now, just basic validation
685    // Will expand with actual command execution and output checking
686}
687
688impl LessonValidator {
689    pub fn new() -> Self {
690        Self {}
691    }
692
693    /// Validate a command exercise
694    pub fn validate_command(
695        &self,
696        user_input: &str,
697        expected: &str,
698        validation: &CommandValidation,
699    ) -> ValidationResult {
700        match validation {
701            CommandValidation::Exact => {
702                let normalized_input = user_input.trim().split_whitespace().collect::<Vec<_>>().join(" ");
703                let normalized_expected = expected.trim().split_whitespace().collect::<Vec<_>>().join(" ");
704
705                if normalized_input == normalized_expected {
706                    ValidationResult::Success {
707                        message: "Perfect! That's exactly right.".to_string(),
708                    }
709                } else {
710                    ValidationResult::Failure {
711                        message: format!("Not quite. Expected: {}", expected),
712                        hint: Some("Check the command and flags carefully.".to_string()),
713                    }
714                }
715            }
716            CommandValidation::CommandOnly => {
717                let user_cmd = user_input.trim().split_whitespace().next().unwrap_or("");
718                let expected_cmd = expected.trim().split_whitespace().next().unwrap_or("");
719
720                if user_cmd == expected_cmd {
721                    ValidationResult::Success {
722                        message: "Correct command!".to_string(),
723                    }
724                } else {
725                    ValidationResult::Failure {
726                        message: format!("Wrong command. Expected: {}", expected_cmd),
727                        hint: None,
728                    }
729                }
730            }
731            CommandValidation::AnyOf(commands) => {
732                let normalized_input = user_input.trim().split_whitespace().collect::<Vec<_>>().join(" ");
733
734                for cmd in commands {
735                    let normalized_cmd = cmd.trim().split_whitespace().collect::<Vec<_>>().join(" ");
736                    if normalized_input == normalized_cmd {
737                        return ValidationResult::Success {
738                            message: "Correct!".to_string(),
739                        };
740                    }
741                }
742
743                ValidationResult::Failure {
744                    message: "Not quite. Try one of the expected commands.".to_string(),
745                    hint: Some(format!("Expected one of: {}", commands.join(" OR "))),
746                }
747            }
748            CommandValidation::CommandAndFlags => {
749                let (user_cmd, user_flags, user_args) = Self::split_command(user_input);
750                let (expected_cmd, expected_flags, expected_args) = Self::split_command(expected);
751
752                if user_cmd.is_empty() {
753                    return ValidationResult::Failure {
754                        message: "No command entered.".to_string(),
755                        hint: Some(format!("Try: {}", expected)),
756                    };
757                }
758
759                if user_cmd != expected_cmd {
760                    return ValidationResult::Failure {
761                        message: format!("Wrong command. Expected: {}", expected_cmd),
762                        hint: Some("Check which program you're running.".to_string()),
763                    };
764                }
765
766                if user_flags != expected_flags {
767                    let expected_list: Vec<String> = expected_flags.iter().cloned().collect();
768                    return ValidationResult::Failure {
769                        message: "The flags don't match.".to_string(),
770                        hint: Some(if expected_list.is_empty() {
771                            "This command doesn't need any flags.".to_string()
772                        } else {
773                            format!("Expected flags: {}", expected_list.join(" "))
774                        }),
775                    };
776                }
777
778                if user_args != expected_args {
779                    return ValidationResult::Failure {
780                        message: "The arguments don't match.".to_string(),
781                        hint: Some(format!("Expected: {}", expected)),
782                    };
783                }
784
785                ValidationResult::Success {
786                    message: "Correct command, flags, and arguments!".to_string(),
787                }
788            }
789            CommandValidation::Regex(pattern) => match regex::Regex::new(pattern) {
790                Ok(re) => {
791                    if re.is_match(user_input.trim()) {
792                        ValidationResult::Success {
793                            message: "Correct!".to_string(),
794                        }
795                    } else {
796                        ValidationResult::Failure {
797                            message: "That command doesn't match what's expected.".to_string(),
798                            hint: None,
799                        }
800                    }
801                }
802                Err(e) => ValidationResult::Failure {
803                    message: format!("Invalid validation pattern in lesson data: {}", e),
804                    hint: None,
805                },
806            },
807        }
808    }
809
810    /// Split a command line into (program, flag set, positional args).
811    ///
812    /// Combined short flags are expanded so `-la`, `-al`, and `-l -a` all
813    /// produce the flag set {"-l", "-a"}. Long flags (`--foo`) are kept whole.
814    /// Flag comparison is order-insensitive; positional args keep their order.
815    fn split_command(input: &str) -> (String, std::collections::BTreeSet<String>, Vec<String>) {
816        let tokens = shellwords::split(input.trim())
817            .unwrap_or_else(|_| input.split_whitespace().map(String::from).collect());
818
819        let mut iter = tokens.into_iter();
820        let program = iter.next().unwrap_or_default();
821
822        let mut flags = std::collections::BTreeSet::new();
823        let mut args = Vec::new();
824
825        for token in iter {
826            if token.starts_with("--") && token.len() > 2 {
827                flags.insert(token);
828            } else if token.starts_with('-') && token.len() > 1 {
829                // Expand combined short flags: -la -> -l, -a
830                for ch in token.chars().skip(1) {
831                    flags.insert(format!("-{}", ch));
832                }
833            } else {
834                args.push(token);
835            }
836        }
837
838        (program, flags, args)
839    }
840
841    /// Validate a multiple choice answer
842    pub fn validate_multiple_choice(&self, user_choice: usize, correct: usize) -> ValidationResult {
843        if user_choice == correct {
844            ValidationResult::Success {
845                message: "Correct!".to_string(),
846            }
847        } else {
848            ValidationResult::Failure {
849                message: "Not quite. Try again!".to_string(),
850                hint: None,
851            }
852        }
853    }
854}
855
856impl Default for LessonValidator {
857    fn default() -> Self {
858        Self::new()
859    }
860}
861
862/// Result of validating user input for a lesson step
863#[derive(Debug, Clone)]
864pub enum ValidationResult {
865    Success { message: String },
866    Failure { message: String, hint: Option<String> },
867    Partial { message: String, progress: f32 },
868}
869
870impl ValidationResult {
871    pub fn is_success(&self) -> bool {
872        matches!(self, ValidationResult::Success { .. })
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879
880    fn create_navigation_basics_lesson() -> Lesson {
881        LessonLibrary::new().get("nav-basics").expect("built-in").clone()
882    }
883
884    #[test]
885    fn test_lesson_progress_new() {
886        let progress = LessonProgress::new("test-lesson".to_string());
887        assert_eq!(progress.lesson_id, "test-lesson");
888        assert_eq!(progress.current_step, 1);
889        assert!(!progress.is_completed());
890    }
891
892    #[test]
893    fn test_lesson_progress_completion() {
894        let mut progress = LessonProgress::new("test-lesson".to_string());
895        progress.complete_step(1);
896        progress.complete_step(2);
897        progress.complete_step(3);
898
899        assert_eq!(progress.completion_percentage(3), 100.0);
900        assert_eq!(progress.completion_percentage(6), 50.0);
901    }
902
903    #[test]
904    fn test_command_validation_exact() {
905        let validator = LessonValidator::new();
906        let result = validator.validate_command(
907            "ls -la",
908            "ls -la",
909            &CommandValidation::Exact,
910        );
911        assert!(result.is_success());
912
913        let result = validator.validate_command(
914            "ls -l",
915            "ls -la",
916            &CommandValidation::Exact,
917        );
918        assert!(!result.is_success());
919    }
920
921    #[test]
922    fn test_command_validation_command_only() {
923        let validator = LessonValidator::new();
924        let result = validator.validate_command(
925            "ls -la /home",
926            "ls",
927            &CommandValidation::CommandOnly,
928        );
929        assert!(result.is_success());
930
931        // Wrong command
932        let result = validator.validate_command("cd /home", "ls", &CommandValidation::CommandOnly);
933        assert!(!result.is_success());
934
935        // Empty input
936        let result = validator.validate_command("", "ls", &CommandValidation::CommandOnly);
937        assert!(!result.is_success());
938    }
939
940    #[test]
941    fn test_command_validation_exact_negative() {
942        let validator = LessonValidator::new();
943        let validation = CommandValidation::Exact;
944
945        // Wrong command
946        assert!(!validator.validate_command("pwd", "ls -la", &validation).is_success());
947        // Wrong flags
948        assert!(!validator.validate_command("ls -l", "ls -la", &validation).is_success());
949        // Empty input
950        assert!(!validator.validate_command("", "ls -la", &validation).is_success());
951    }
952
953    #[test]
954    fn test_command_validation_command_and_flags() {
955        let validator = LessonValidator::new();
956        let validation = CommandValidation::CommandAndFlags;
957
958        // Exact match
959        assert!(validator
960            .validate_command("ls -la /home", "ls -la /home", &validation)
961            .is_success());
962
963        // Flags are order-insensitive and combined short flags expand:
964        // -la == -al == -l -a
965        assert!(validator
966            .validate_command("ls -al /home", "ls -la /home", &validation)
967            .is_success());
968        assert!(validator
969            .validate_command("ls -l -a /home", "ls -la /home", &validation)
970            .is_success());
971        assert!(validator
972            .validate_command("ls -a -l /home", "ls -l -a /home", &validation)
973            .is_success());
974
975        // Wrong command
976        assert!(!validator
977            .validate_command("dir -la /home", "ls -la /home", &validation)
978            .is_success());
979
980        // Wrong flags (missing / extra)
981        assert!(!validator
982            .validate_command("ls -l /home", "ls -la /home", &validation)
983            .is_success());
984        assert!(!validator
985            .validate_command("ls -lah /home", "ls -la /home", &validation)
986            .is_success());
987
988        // Wrong positional args
989        assert!(!validator
990            .validate_command("ls -la /tmp", "ls -la /home", &validation)
991            .is_success());
992
993        // Empty input
994        assert!(!validator.validate_command("", "ls -la /home", &validation).is_success());
995    }
996
997    #[test]
998    fn test_command_validation_regex() {
999        let validator = LessonValidator::new();
1000        let validation = CommandValidation::Regex("ps.*8080|lsof.*8080".to_string());
1001
1002        // Matching input
1003        assert!(validator
1004            .validate_command("ps aux | grep 8080", "", &validation)
1005            .is_success());
1006        assert!(validator
1007            .validate_command("lsof -i :8080", "", &validation)
1008            .is_success());
1009
1010        // Wrong command
1011        assert!(!validator.validate_command("ls -la", "", &validation).is_success());
1012        // Empty input
1013        assert!(!validator.validate_command("", "", &validation).is_success());
1014
1015        // Invalid pattern must fail, not silently pass
1016        let bad = CommandValidation::Regex("(unclosed".to_string());
1017        assert!(!validator.validate_command("anything", "", &bad).is_success());
1018    }
1019
1020    #[test]
1021    fn test_command_validation_any_of_negative() {
1022        let validator = LessonValidator::new();
1023        let validation = CommandValidation::AnyOf(vec!["cd".to_string(), "cd ~".to_string()]);
1024
1025        assert!(validator.validate_command("cd ~", "cd", &validation).is_success());
1026        // Wrong command
1027        assert!(!validator.validate_command("pwd", "cd", &validation).is_success());
1028        // Wrong flags/args
1029        assert!(!validator.validate_command("cd /tmp", "cd", &validation).is_success());
1030        // Empty input
1031        assert!(!validator.validate_command("", "cd", &validation).is_success());
1032    }
1033
1034    #[test]
1035    fn test_lesson_toml_round_trip() {
1036        let lesson = create_navigation_basics_lesson();
1037
1038        let toml_text = lesson.to_toml().expect("Lesson should serialize to TOML");
1039        let restored = Lesson::from_toml(&toml_text).expect("TOML should parse back to Lesson");
1040
1041        assert_eq!(restored.id, lesson.id);
1042        assert_eq!(restored.title, lesson.title);
1043        assert_eq!(restored.difficulty, lesson.difficulty);
1044        assert_eq!(restored.steps.len(), lesson.steps.len());
1045        assert_eq!(restored.prerequisites, lesson.prerequisites);
1046        assert_eq!(restored.tags, lesson.tags);
1047        assert_eq!(restored.setup, lesson.setup);
1048    }
1049
1050    #[test]
1051    fn test_lesson_toml_without_setup_still_parses() {
1052        // Backward compat: lesson-pack TOML written before the `setup` field
1053        // existed must keep parsing (setup defaults to empty)
1054        let mut lesson = create_navigation_basics_lesson();
1055        lesson.setup.clear();
1056        let toml_text = lesson.to_toml().unwrap();
1057        assert!(
1058            !toml_text.contains("[[setup]]"),
1059            "empty setup should not serialize an array"
1060        );
1061
1062        let restored = Lesson::from_toml(&toml_text).expect("legacy TOML should parse");
1063        assert!(restored.setup.is_empty());
1064    }
1065
1066    #[test]
1067    fn test_lesson_toml_setup_round_trip() {
1068        let mut lesson = create_navigation_basics_lesson();
1069        lesson.setup = vec![SetupFile {
1070            path: "data/sample.txt".to_string(),
1071            contents: "hello\nworld\n".to_string(),
1072        }];
1073
1074        let toml_text = lesson.to_toml().unwrap();
1075        assert!(toml_text.contains("[[setup]]"));
1076
1077        let restored = Lesson::from_toml(&toml_text).unwrap();
1078        assert_eq!(restored.setup.len(), 1);
1079        assert_eq!(restored.setup[0].path, "data/sample.txt");
1080        assert_eq!(restored.setup[0].contents, "hello\nworld\n");
1081    }
1082
1083    #[test]
1084    fn test_builtin_lessons_have_starter_files_where_needed() {
1085        let library = LessonLibrary::new();
1086        for id in ["file-viewing", "file-mgmt", "text-processing"] {
1087            let lesson = library.get(id).expect("built-in lesson should exist");
1088            assert!(
1089                !lesson.setup.is_empty(),
1090                "lesson '{}' should ship starter files",
1091                id
1092            );
1093            for file in &lesson.setup {
1094                assert!(!file.path.starts_with('/'), "setup path must be relative");
1095                assert!(!file.path.contains(".."), "setup path must not contain ..");
1096                assert!(!file.contents.is_empty(), "setup contents must not be empty");
1097            }
1098        }
1099    }
1100
1101    #[test]
1102    fn test_library_load_from_dir() {
1103        let dir = std::env::temp_dir().join(format!(
1104            "arct-lesson-dir-test-{}",
1105            std::process::id()
1106        ));
1107        std::fs::create_dir_all(&dir).unwrap();
1108
1109        // Write an example user lesson that overrides a built-in by id
1110        let mut custom = create_navigation_basics_lesson();
1111        custom.title = "Navigation Basics (User Override)".to_string();
1112        std::fs::write(dir.join("nav-basics.toml"), custom.to_toml().unwrap()).unwrap();
1113
1114        // And a brand-new user lesson
1115        let mut extra = create_navigation_basics_lesson();
1116        extra.id = "user-extra".to_string();
1117        extra.title = "User Extra Lesson".to_string();
1118        std::fs::write(dir.join("user-extra.toml"), extra.to_toml().unwrap()).unwrap();
1119
1120        // Non-TOML files are ignored
1121        std::fs::write(dir.join("notes.txt"), "not a lesson").unwrap();
1122
1123        let mut library = LessonLibrary::new();
1124        let builtin_count = library.all().len();
1125        let loaded = library.load_from_dir(&dir).expect("load_from_dir should succeed");
1126
1127        assert_eq!(loaded, 2);
1128        // Override replaces, new lesson adds
1129        assert_eq!(library.all().len(), builtin_count + 1);
1130        assert_eq!(
1131            library.get("nav-basics").unwrap().title,
1132            "Navigation Basics (User Override)"
1133        );
1134        assert_eq!(library.get("user-extra").unwrap().title, "User Extra Lesson");
1135
1136        // Missing directory is not an error
1137        let mut fresh = LessonLibrary::new();
1138        assert_eq!(fresh.load_from_dir(&dir.join("does-not-exist")).unwrap(), 0);
1139
1140        let _ = std::fs::remove_dir_all(&dir);
1141    }
1142
1143    #[test]
1144    fn test_library_order_is_stable_and_respects_prerequisites() {
1145        let library = LessonLibrary::new();
1146        let ids: Vec<&str> = library.all().iter().map(|l| l.id.as_str()).collect();
1147        assert!(ids.contains(&"nav-basics") && ids.contains(&"explorer-look-around"));
1148        let other = LessonLibrary::new();
1149        let again: Vec<&str> = other.all().iter().map(|l| l.id.as_str()).collect();
1150        assert_eq!(ids, again, "order must not change between runs");
1151        for (i, lesson) in library.all().iter().enumerate() {
1152            for prereq in &lesson.prerequisites {
1153                let at = ids.iter().position(|id| id == prereq).expect("prereq exists");
1154                assert!(at < i, "{} must come after {}", lesson.id, prereq);
1155            }
1156        }
1157    }
1158}