1use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9#[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>, pub tags: Vec<String>, #[serde(default)]
67 pub setup: Vec<SetupFile>,
68 #[serde(default)]
71 pub track: Track,
72 #[serde(default)]
76 pub simulated_only: bool,
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
82pub enum Track {
83 Explorer,
84 #[default]
85 Operator,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct SetupFile {
94 pub path: String,
96 pub contents: String,
98}
99
100impl Lesson {
101 pub fn to_toml(&self) -> anyhow::Result<String> {
103 Ok(toml::to_string_pretty(self)?)
104 }
105
106 pub fn from_toml(content: &str) -> anyhow::Result<Self> {
108 Ok(toml::from_str(content)?)
109 }
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114pub enum Difficulty {
115 Beginner,
116 Intermediate,
117 Advanced,
118 Expert,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct LessonStep {
124 pub step_number: u32,
125 pub title: String,
126 pub instruction: String,
127 pub hint: Option<String>,
129 pub step_type: StepType,
130 #[serde(default)]
133 pub hints: Vec<String>,
134 #[serde(default)]
136 pub challenge: bool,
137 #[serde(default)]
141 pub solution: Vec<String>,
142}
143
144impl LessonStep {
145 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#[derive(Debug, Clone, Serialize, Deserialize)]
157pub enum StepType {
158 CommandExercise {
160 expected_command: String,
161 validation: CommandValidation,
162 success_message: String,
163 },
164 MultipleChoice {
166 question: String,
167 options: Vec<String>,
168 correct_index: usize,
169 explanation: String,
170 },
171 FillInBlank {
173 template: String, correct_answers: Vec<String>, explanation: String,
176 },
177 Information {
179 content: String,
180 },
181 Practice {
183 goal: String,
184 validation: PracticeValidation,
185 hints: Vec<String>,
186 },
187 Task {
190 goal: String,
191 checks: Vec<Check>,
192 success_message: String,
193 },
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
199pub enum Check {
200 DirExists(String),
202 FileExists(String),
204 Missing(String),
206 FileContains { path: String, text: String },
208 CwdIs(String),
210 OutputContains(String),
212 Used(String),
214}
215
216pub trait PracticeWorld {
218 fn locate(&self, lesson_path: &str) -> Option<std::path::PathBuf>;
220 fn cwd_relative(&self) -> String;
222 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 fn read_text(&self, lesson_path: &str) -> Option<String> {
240 std::fs::read_to_string(self.locate(lesson_path)?).ok()
241 }
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum EntryKind {
247 Missing,
248 File,
249 Dir,
250 Other,
252}
253
254pub struct Attempt<'a> {
256 pub input: &'a str,
257 pub output: &'a str,
258}
259
260pub 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
301pub(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
317pub 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
411pub enum CommandValidation {
412 Exact,
414 CommandAndFlags,
416 CommandOnly,
418 Regex(String),
420 AnyOf(Vec<String>),
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize)]
426pub enum PracticeValidation {
427 FileExists(String),
429 DirectoryExists(String),
431 FileContains { path: String, content: String },
433 OutputMatches { command: String, pattern: String },
435 Custom(String),
437}
438
439#[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>, }
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#[derive(Debug, Clone)]
491pub struct LessonLibrary {
492 lessons: HashMap<String, Lesson>,
493 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 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 pub fn get(&self, id: &str) -> Option<&Lesson> {
517 self.lessons.get(id)
518 }
519
520 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 break;
543 }
544 }
545 if ordered.len() == before {
546 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 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 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 pub fn difficulty_counts(&self) -> HashMap<Difficulty, usize> {
576 let mut counts = HashMap::new();
577 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 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 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
630const 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
682pub struct LessonValidator {
684 }
687
688impl LessonValidator {
689 pub fn new() -> Self {
690 Self {}
691 }
692
693 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 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 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 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#[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 let result = validator.validate_command("cd /home", "ls", &CommandValidation::CommandOnly);
933 assert!(!result.is_success());
934
935 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 assert!(!validator.validate_command("pwd", "ls -la", &validation).is_success());
947 assert!(!validator.validate_command("ls -l", "ls -la", &validation).is_success());
949 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 assert!(validator
960 .validate_command("ls -la /home", "ls -la /home", &validation)
961 .is_success());
962
963 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 assert!(!validator
977 .validate_command("dir -la /home", "ls -la /home", &validation)
978 .is_success());
979
980 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 assert!(!validator
990 .validate_command("ls -la /tmp", "ls -la /home", &validation)
991 .is_success());
992
993 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 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 assert!(!validator.validate_command("ls -la", "", &validation).is_success());
1012 assert!(!validator.validate_command("", "", &validation).is_success());
1014
1015 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 assert!(!validator.validate_command("pwd", "cd", &validation).is_success());
1028 assert!(!validator.validate_command("cd /tmp", "cd", &validation).is_success());
1030 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 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 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 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 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 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 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}