1use crate::lesson::{Difficulty, Lesson, LessonLibrary};
8use crate::stats::UserStats;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LessonRecommendation {
15 pub lesson: Lesson,
16 pub reason: RecommendationReason,
17 pub priority: u8, }
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum RecommendationReason {
23 NextInSequence,
25 PrerequisiteSatisfied,
27 SameDifficulty,
29 PopularNext,
31 FillGap,
33 Review,
35 SkillLevelMatch,
37 RelatedTopic,
39}
40
41pub struct RecommendationEngine {
43 library: LessonLibrary,
44}
45
46impl RecommendationEngine {
47 pub fn new() -> Self {
49 Self {
50 library: LessonLibrary::new(),
51 }
52 }
53
54 pub fn get_recommendations(
58 &self,
59 completed_lessons: &HashSet<String>,
60 stats: &UserStats,
61 max_recommendations: usize,
62 ) -> Vec<LessonRecommendation> {
63 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
64 let mut recommendations = Vec::new();
65
66 for lesson in all_lessons {
67 if completed_lessons.contains(&lesson.id) {
69 continue;
70 }
71
72 if !self.check_prerequisites(&lesson, completed_lessons) {
74 continue;
75 }
76
77 let (priority, reason) =
79 self.calculate_priority_and_reason(&lesson, completed_lessons, stats);
80
81 if priority > 0 {
83 recommendations.push(LessonRecommendation {
84 lesson,
85 reason,
86 priority,
87 });
88 }
89 }
90
91 recommendations.sort_by(|a, b| b.priority.cmp(&a.priority));
93
94 recommendations.truncate(max_recommendations);
96 recommendations
97 }
98
99 fn calculate_priority_and_reason(
101 &self,
102 lesson: &Lesson,
103 completed: &HashSet<String>,
104 stats: &UserStats,
105 ) -> (u8, RecommendationReason) {
106 let mut priority = 0u8;
107 let mut reason = RecommendationReason::SkillLevelMatch;
108
109 if !lesson.prerequisites.is_empty() {
111 let all_prereqs_done = lesson
112 .prerequisites
113 .iter()
114 .all(|prereq| completed.contains(prereq));
115
116 if all_prereqs_done {
117 priority += 8;
118 reason = RecommendationReason::PrerequisiteSatisfied;
119 }
120 }
121
122 if completed.is_empty() && lesson.difficulty == Difficulty::Beginner {
124 priority = 10;
125 reason = RecommendationReason::SkillLevelMatch;
126 }
127
128 let user_skill_level = self.estimate_user_skill_level(completed, stats);
130 if lesson.difficulty == user_skill_level {
131 priority += 6;
132 if priority > 6 {
133 } else {
135 reason = RecommendationReason::SameDifficulty;
136 }
137 } else if self.is_next_difficulty_level(user_skill_level, lesson.difficulty) {
138 priority += 5;
139 reason = RecommendationReason::NextInSequence;
140 }
141
142 if self.has_related_topics(lesson, completed) {
144 priority += 4;
145 if priority <= 4 {
146 reason = RecommendationReason::RelatedTopic;
147 }
148 }
149
150 if self.fills_knowledge_gap(lesson, completed) {
152 priority += 3;
153 if priority <= 3 {
154 reason = RecommendationReason::FillGap;
155 }
156 }
157
158 if lesson.estimated_minutes <= 10 && stats.total_time_seconds < 1800 {
160 priority += 2; }
162
163 if completed.len() < 3 && lesson.difficulty == Difficulty::Advanced {
165 priority = priority.saturating_sub(8);
166 }
167 if completed.len() < 5 && lesson.difficulty == Difficulty::Expert {
168 priority = priority.saturating_sub(10);
169 }
170
171 if self.is_too_difficult(user_skill_level, lesson.difficulty) {
173 priority = priority.saturating_sub(7);
174 }
175
176 (priority.min(10), reason)
177 }
178
179 fn check_prerequisites(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
181 lesson.prerequisites.iter().all(|prereq| completed.contains(prereq))
182 }
183
184 fn estimate_user_skill_level(
186 &self,
187 completed: &HashSet<String>,
188 stats: &UserStats,
189 ) -> Difficulty {
190 if completed.is_empty() {
191 return Difficulty::Beginner;
192 }
193
194 let beginner_count = stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap_or(&0);
196 let intermediate_count = stats.lessons_by_difficulty.get(&Difficulty::Intermediate).unwrap_or(&0);
197 let advanced_count = stats.lessons_by_difficulty.get(&Difficulty::Advanced).unwrap_or(&0);
198 let expert_count = stats.lessons_by_difficulty.get(&Difficulty::Expert).unwrap_or(&0);
199
200 if *expert_count >= 3 {
202 Difficulty::Expert
203 } else if *advanced_count >= 3 || (*intermediate_count >= 5 && *advanced_count >= 1) {
204 Difficulty::Advanced
205 } else if *intermediate_count >= 2 || (*beginner_count >= 5 && *intermediate_count >= 1) {
206 Difficulty::Intermediate
207 } else {
208 Difficulty::Beginner
209 }
210 }
211
212 fn is_next_difficulty_level(&self, current: Difficulty, lesson: Difficulty) -> bool {
214 matches!(
215 (current, lesson),
216 (Difficulty::Beginner, Difficulty::Intermediate)
217 | (Difficulty::Intermediate, Difficulty::Advanced)
218 | (Difficulty::Advanced, Difficulty::Expert)
219 )
220 }
221
222 fn is_too_difficult(&self, current: Difficulty, lesson: Difficulty) -> bool {
224 match (current, lesson) {
225 (Difficulty::Beginner, Difficulty::Advanced) => true,
226 (Difficulty::Beginner, Difficulty::Expert) => true,
227 (Difficulty::Intermediate, Difficulty::Expert) => true,
228 _ => false,
229 }
230 }
231
232 fn has_related_topics(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
234 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
235
236 let completed_tags: HashSet<String> = all_lessons
238 .iter()
239 .filter(|l| completed.contains(&l.id))
240 .flat_map(|l| l.tags.clone())
241 .collect();
242
243 lesson.tags.iter().any(|tag| completed_tags.contains(tag))
245 }
246
247 fn fills_knowledge_gap(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
249 if lesson.prerequisites.is_empty() && !lesson.tags.is_empty() {
254 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
255 let completed_tags: HashSet<String> = all_lessons
256 .iter()
257 .filter(|l| completed.contains(&l.id))
258 .flat_map(|l| l.tags.clone())
259 .collect();
260
261 lesson.tags.iter().any(|tag| !completed_tags.contains(tag))
263 } else {
264 false
265 }
266 }
267
268 pub fn get_recommendations_by_difficulty(
270 &self,
271 completed_lessons: &HashSet<String>,
272 difficulty: Difficulty,
273 max_recommendations: usize,
274 ) -> Vec<LessonRecommendation> {
275 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
276 let mut recommendations = Vec::new();
277
278 for lesson in all_lessons {
279 if completed_lessons.contains(&lesson.id) {
280 continue;
281 }
282
283 if lesson.difficulty != difficulty {
284 continue;
285 }
286
287 if !self.check_prerequisites(&lesson, completed_lessons) {
288 continue;
289 }
290
291 recommendations.push(LessonRecommendation {
292 lesson,
293 reason: RecommendationReason::SameDifficulty,
294 priority: 7,
295 });
296 }
297
298 recommendations.truncate(max_recommendations);
299 recommendations
300 }
301
302 pub fn get_next_lesson(
304 &self,
305 completed_lessons: &HashSet<String>,
306 stats: &UserStats,
307 ) -> Option<LessonRecommendation> {
308 self.get_recommendations(completed_lessons, stats, 1).into_iter().next()
309 }
310
311 pub fn get_available_lessons(
313 &self,
314 completed_lessons: &HashSet<String>,
315 ) -> Vec<Lesson> {
316 let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
317
318 all_lessons
319 .into_iter()
320 .filter(|lesson| {
321 !completed_lessons.contains(&lesson.id)
322 && self.check_prerequisites(lesson, completed_lessons)
323 })
324 .collect()
325 }
326}
327
328impl Default for RecommendationEngine {
329 fn default() -> Self {
330 Self::new()
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_recommendation_engine_creation() {
340 let engine = RecommendationEngine::new();
341 let completed = HashSet::new();
342 let stats = UserStats::new();
343 let recommendations = engine.get_recommendations(&completed, &stats, 5);
344
345 assert!(!recommendations.is_empty());
347 }
348
349 #[test]
350 fn test_beginner_recommendations() {
351 let engine = RecommendationEngine::new();
352 let completed = HashSet::new();
353 let stats = UserStats::new();
354 let recommendations = engine.get_recommendations(&completed, &stats, 5);
355
356 for rec in recommendations {
358 assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
359 }
360 }
361
362 #[test]
363 fn test_skill_level_estimation() {
364 let engine = RecommendationEngine::new();
365 let mut stats = UserStats::new();
366 let completed = HashSet::new();
367
368 let level = engine.estimate_user_skill_level(&completed, &stats);
370 assert_eq!(level, Difficulty::Beginner);
371
372 stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
374 stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
375 let level = engine.estimate_user_skill_level(&completed, &stats);
376 assert_eq!(level, Difficulty::Beginner);
377 }
378
379 #[test]
380 fn test_prerequisite_checking() {
381 let engine = RecommendationEngine::new();
382 let library = LessonLibrary::new();
383 let lessons: Vec<Lesson> = library.all().into_iter().cloned().collect();
384
385 if let Some(lesson_with_prereq) = lessons.iter().find(|l| !l.prerequisites.is_empty()) {
386 let completed = HashSet::new();
387 assert!(!engine.check_prerequisites(lesson_with_prereq, &completed));
388
389 let mut completed_with_prereq = HashSet::new();
390 for prereq in &lesson_with_prereq.prerequisites {
391 completed_with_prereq.insert(prereq.clone());
392 }
393 assert!(engine.check_prerequisites(lesson_with_prereq, &completed_with_prereq));
394 }
395 }
396
397 #[test]
398 fn test_difficulty_progression() {
399 let engine = RecommendationEngine::new();
400
401 assert!(engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Intermediate));
402 assert!(engine.is_next_difficulty_level(Difficulty::Intermediate, Difficulty::Advanced));
403 assert!(!engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Expert));
404 }
405
406 #[test]
407 fn test_too_difficult_check() {
408 let engine = RecommendationEngine::new();
409
410 assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Advanced));
411 assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Expert));
412 assert!(!engine.is_too_difficult(Difficulty::Beginner, Difficulty::Intermediate));
413 assert!(!engine.is_too_difficult(Difficulty::Intermediate, Difficulty::Advanced));
414 }
415
416 #[test]
417 fn test_get_next_lesson() {
418 let engine = RecommendationEngine::new();
419 let completed = HashSet::new();
420 let stats = UserStats::new();
421
422 let next = engine.get_next_lesson(&completed, &stats);
423 assert!(next.is_some());
424
425 if let Some(recommendation) = next {
426 assert_eq!(recommendation.lesson.difficulty, Difficulty::Beginner);
427 assert!(recommendation.priority > 0);
428 }
429 }
430
431 #[test]
432 fn test_available_lessons() {
433 let engine = RecommendationEngine::new();
434 let completed = HashSet::new();
435
436 let available = engine.get_available_lessons(&completed);
437 assert!(!available.is_empty());
438
439 for lesson in available {
441 assert!(lesson.prerequisites.is_empty() ||
442 lesson.prerequisites.iter().all(|p| completed.contains(p)));
443 }
444 }
445
446 #[test]
447 fn test_recommendations_by_difficulty() {
448 let engine = RecommendationEngine::new();
449 let completed = HashSet::new();
450
451 let beginner_recs = engine.get_recommendations_by_difficulty(
452 &completed,
453 Difficulty::Beginner,
454 5,
455 );
456
457 for rec in beginner_recs {
458 assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
459 }
460 }
461
462 #[test]
463 fn test_exclude_completed_lessons() {
464 let engine = RecommendationEngine::new();
465 let mut completed = HashSet::new();
466 let stats = UserStats::new();
467
468 let first_rec = engine.get_next_lesson(&completed, &stats);
469 assert!(first_rec.is_some());
470
471 if let Some(rec) = first_rec {
472 completed.insert(rec.lesson.id.clone());
473 let next_rec = engine.get_next_lesson(&completed, &stats);
474
475 if let Some(next) = next_rec {
476 assert_ne!(next.lesson.id, rec.lesson.id);
477 }
478 }
479 }
480}