use crate::lesson::{Difficulty, Lesson, LessonLibrary};
use crate::stats::UserStats;
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LessonRecommendation {
pub lesson: Lesson,
pub reason: RecommendationReason,
pub priority: u8, }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum RecommendationReason {
NextInSequence,
PrerequisiteSatisfied,
SameDifficulty,
PopularNext,
FillGap,
Review,
SkillLevelMatch,
RelatedTopic,
}
pub struct RecommendationEngine {
library: LessonLibrary,
}
impl RecommendationEngine {
pub fn new() -> Self {
Self {
library: LessonLibrary::new(),
}
}
pub fn with_library(library: LessonLibrary) -> Self {
Self { library }
}
pub fn get_recommendations(
&self,
completed_lessons: &HashSet<String>,
stats: &UserStats,
max_recommendations: usize,
) -> Vec<LessonRecommendation> {
let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
let mut recommendations = Vec::new();
for lesson in all_lessons {
if completed_lessons.contains(&lesson.id) {
continue;
}
if !self.check_prerequisites(&lesson, completed_lessons) {
continue;
}
let (priority, reason) =
self.calculate_priority_and_reason(&lesson, completed_lessons, stats);
if priority > 0 {
recommendations.push(LessonRecommendation {
lesson,
reason,
priority,
});
}
}
recommendations.sort_by(|a, b| b.priority.cmp(&a.priority));
recommendations.truncate(max_recommendations);
recommendations
}
fn calculate_priority_and_reason(
&self,
lesson: &Lesson,
completed: &HashSet<String>,
stats: &UserStats,
) -> (u8, RecommendationReason) {
let mut priority = 0u8;
let mut reason = RecommendationReason::SkillLevelMatch;
if !lesson.prerequisites.is_empty() {
let all_prereqs_done = lesson
.prerequisites
.iter()
.all(|prereq| completed.contains(prereq));
if all_prereqs_done {
priority += 8;
reason = RecommendationReason::PrerequisiteSatisfied;
}
}
if completed.is_empty() && lesson.difficulty == Difficulty::Beginner {
priority = 10;
reason = RecommendationReason::SkillLevelMatch;
}
let user_skill_level = self.estimate_user_skill_level(completed, stats);
if lesson.difficulty == user_skill_level {
priority += 6;
if priority > 6 {
} else {
reason = RecommendationReason::SameDifficulty;
}
} else if self.is_next_difficulty_level(user_skill_level, lesson.difficulty) {
priority += 5;
reason = RecommendationReason::NextInSequence;
}
if self.has_related_topics(lesson, completed) {
priority += 4;
if priority <= 4 {
reason = RecommendationReason::RelatedTopic;
}
}
if self.fills_knowledge_gap(lesson, completed) {
priority += 3;
if priority <= 3 {
reason = RecommendationReason::FillGap;
}
}
if lesson.estimated_minutes <= 10 && stats.total_time_seconds < 1800 {
priority += 2; }
if completed.len() < 3 && lesson.difficulty == Difficulty::Advanced {
priority = priority.saturating_sub(8);
}
if completed.len() < 5 && lesson.difficulty == Difficulty::Expert {
priority = priority.saturating_sub(10);
}
if self.is_too_difficult(user_skill_level, lesson.difficulty) {
priority = priority.saturating_sub(7);
}
(priority.min(10), reason)
}
fn check_prerequisites(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
lesson.prerequisites.iter().all(|prereq| completed.contains(prereq))
}
fn estimate_user_skill_level(
&self,
completed: &HashSet<String>,
stats: &UserStats,
) -> Difficulty {
if completed.is_empty() {
return Difficulty::Beginner;
}
let beginner_count = stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap_or(&0);
let intermediate_count = stats.lessons_by_difficulty.get(&Difficulty::Intermediate).unwrap_or(&0);
let advanced_count = stats.lessons_by_difficulty.get(&Difficulty::Advanced).unwrap_or(&0);
let expert_count = stats.lessons_by_difficulty.get(&Difficulty::Expert).unwrap_or(&0);
if *expert_count >= 3 {
Difficulty::Expert
} else if *advanced_count >= 3 || (*intermediate_count >= 5 && *advanced_count >= 1) {
Difficulty::Advanced
} else if *intermediate_count >= 2 || (*beginner_count >= 5 && *intermediate_count >= 1) {
Difficulty::Intermediate
} else {
Difficulty::Beginner
}
}
fn is_next_difficulty_level(&self, current: Difficulty, lesson: Difficulty) -> bool {
matches!(
(current, lesson),
(Difficulty::Beginner, Difficulty::Intermediate)
| (Difficulty::Intermediate, Difficulty::Advanced)
| (Difficulty::Advanced, Difficulty::Expert)
)
}
fn is_too_difficult(&self, current: Difficulty, lesson: Difficulty) -> bool {
match (current, lesson) {
(Difficulty::Beginner, Difficulty::Advanced) => true,
(Difficulty::Beginner, Difficulty::Expert) => true,
(Difficulty::Intermediate, Difficulty::Expert) => true,
_ => false,
}
}
fn has_related_topics(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
let completed_tags: HashSet<String> = all_lessons
.iter()
.filter(|l| completed.contains(&l.id))
.flat_map(|l| l.tags.clone())
.collect();
lesson.tags.iter().any(|tag| completed_tags.contains(tag))
}
fn fills_knowledge_gap(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
if lesson.prerequisites.is_empty() && !lesson.tags.is_empty() {
let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
let completed_tags: HashSet<String> = all_lessons
.iter()
.filter(|l| completed.contains(&l.id))
.flat_map(|l| l.tags.clone())
.collect();
lesson.tags.iter().any(|tag| !completed_tags.contains(tag))
} else {
false
}
}
pub fn get_recommendations_by_difficulty(
&self,
completed_lessons: &HashSet<String>,
difficulty: Difficulty,
max_recommendations: usize,
) -> Vec<LessonRecommendation> {
let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
let mut recommendations = Vec::new();
for lesson in all_lessons {
if completed_lessons.contains(&lesson.id) {
continue;
}
if lesson.difficulty != difficulty {
continue;
}
if !self.check_prerequisites(&lesson, completed_lessons) {
continue;
}
recommendations.push(LessonRecommendation {
lesson,
reason: RecommendationReason::SameDifficulty,
priority: 7,
});
}
recommendations.truncate(max_recommendations);
recommendations
}
pub fn get_next_lesson(
&self,
completed_lessons: &HashSet<String>,
stats: &UserStats,
) -> Option<LessonRecommendation> {
self.get_recommendations(completed_lessons, stats, 1).into_iter().next()
}
pub fn get_available_lessons(
&self,
completed_lessons: &HashSet<String>,
) -> Vec<Lesson> {
let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
all_lessons
.into_iter()
.filter(|lesson| {
!completed_lessons.contains(&lesson.id)
&& self.check_prerequisites(lesson, completed_lessons)
})
.collect()
}
}
impl Default for RecommendationEngine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recommendation_engine_creation() {
let engine = RecommendationEngine::new();
let completed = HashSet::new();
let stats = UserStats::new();
let recommendations = engine.get_recommendations(&completed, &stats, 5);
assert!(!recommendations.is_empty());
}
#[test]
fn test_beginner_recommendations() {
let engine = RecommendationEngine::new();
let completed = HashSet::new();
let stats = UserStats::new();
let recommendations = engine.get_recommendations(&completed, &stats, 5);
for rec in recommendations {
assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
}
}
#[test]
fn test_skill_level_estimation() {
let engine = RecommendationEngine::new();
let mut stats = UserStats::new();
let completed = HashSet::new();
let level = engine.estimate_user_skill_level(&completed, &stats);
assert_eq!(level, Difficulty::Beginner);
stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
let level = engine.estimate_user_skill_level(&completed, &stats);
assert_eq!(level, Difficulty::Beginner);
}
#[test]
fn test_prerequisite_checking() {
let engine = RecommendationEngine::new();
let library = LessonLibrary::new();
let lessons: Vec<Lesson> = library.all().into_iter().cloned().collect();
if let Some(lesson_with_prereq) = lessons.iter().find(|l| !l.prerequisites.is_empty()) {
let completed = HashSet::new();
assert!(!engine.check_prerequisites(lesson_with_prereq, &completed));
let mut completed_with_prereq = HashSet::new();
for prereq in &lesson_with_prereq.prerequisites {
completed_with_prereq.insert(prereq.clone());
}
assert!(engine.check_prerequisites(lesson_with_prereq, &completed_with_prereq));
}
}
#[test]
fn test_difficulty_progression() {
let engine = RecommendationEngine::new();
assert!(engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Intermediate));
assert!(engine.is_next_difficulty_level(Difficulty::Intermediate, Difficulty::Advanced));
assert!(!engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Expert));
}
#[test]
fn test_too_difficult_check() {
let engine = RecommendationEngine::new();
assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Advanced));
assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Expert));
assert!(!engine.is_too_difficult(Difficulty::Beginner, Difficulty::Intermediate));
assert!(!engine.is_too_difficult(Difficulty::Intermediate, Difficulty::Advanced));
}
#[test]
fn test_get_next_lesson() {
let engine = RecommendationEngine::new();
let completed = HashSet::new();
let stats = UserStats::new();
let next = engine.get_next_lesson(&completed, &stats);
assert!(next.is_some());
if let Some(recommendation) = next {
assert_eq!(recommendation.lesson.difficulty, Difficulty::Beginner);
assert!(recommendation.priority > 0);
}
}
#[test]
fn test_available_lessons() {
let engine = RecommendationEngine::new();
let completed = HashSet::new();
let available = engine.get_available_lessons(&completed);
assert!(!available.is_empty());
for lesson in available {
assert!(lesson.prerequisites.is_empty() ||
lesson.prerequisites.iter().all(|p| completed.contains(p)));
}
}
#[test]
fn test_recommendations_by_difficulty() {
let engine = RecommendationEngine::new();
let completed = HashSet::new();
let beginner_recs = engine.get_recommendations_by_difficulty(
&completed,
Difficulty::Beginner,
5,
);
for rec in beginner_recs {
assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
}
}
#[test]
fn test_exclude_completed_lessons() {
let engine = RecommendationEngine::new();
let mut completed = HashSet::new();
let stats = UserStats::new();
let first_rec = engine.get_next_lesson(&completed, &stats);
assert!(first_rec.is_some());
if let Some(rec) = first_rec {
completed.insert(rec.lesson.id.clone());
let next_rec = engine.get_next_lesson(&completed, &stats);
if let Some(next) = next_rec {
assert_ne!(next.lesson.id, rec.lesson.id);
}
}
}
}