Skip to main content

arct_core/
challenge.rs

1//! Daily and weekly challenge system
2//!
3//! This module provides an engaging challenge system with daily tasks,
4//! weekly scenarios, and speed challenges to motivate continuous learning.
5
6use crate::lesson::{CommandValidation, Difficulty, LessonValidator};
7use crate::stats::UserStats;
8use chrono::{Datelike, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11
12/// Represents a learning challenge that can be completed
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Challenge {
15    pub id: String,
16    pub title: String,
17    pub description: String,
18    pub difficulty: Difficulty,
19    pub challenge_type: ChallengeType,
20    pub points: u32,
21}
22
23/// Different types of challenges available
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub enum ChallengeType {
26    /// A single command task to complete daily
27    DailyCommand {
28        command: String,
29        task_description: String,
30        validation: CommandValidation,
31    },
32    /// Multi-step scenario challenge for weekly engagement
33    WeeklyScenario {
34        scenario: String,
35        steps: Vec<ChallengeStep>,
36    },
37    /// Timed challenge to complete tasks quickly
38    SpeedChallenge {
39        task: String,
40        time_limit_seconds: u32,
41        commands: Vec<String>,
42    },
43}
44
45/// A single step within a multi-step challenge
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ChallengeStep {
48    pub description: String,
49    pub validation: CommandValidation,
50    pub hint: Option<String>,
51}
52
53/// Manages active and completed challenges
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct ChallengeManager {
56    /// Currently active daily challenge
57    pub daily_challenge: Option<Challenge>,
58    /// Currently active weekly challenge
59    pub weekly_challenge: Option<Challenge>,
60    /// Set of completed challenge IDs
61    pub completed_challenges: HashSet<String>,
62    /// Date when daily challenge was last generated
63    last_daily_generated: Option<chrono::NaiveDate>,
64    /// ISO week number when weekly challenge was last generated
65    last_weekly_generated: Option<u32>,
66    /// Progress (completed step count) through the active weekly scenario
67    #[serde(default)]
68    weekly_step_progress: usize,
69}
70
71impl ChallengeManager {
72    /// Create a new challenge manager
73    pub fn new() -> Self {
74        Self {
75            daily_challenge: None,
76            weekly_challenge: None,
77            completed_challenges: HashSet::new(),
78            last_daily_generated: None,
79            last_weekly_generated: None,
80            weekly_step_progress: 0,
81        }
82    }
83
84    /// Get or generate today's daily challenge
85    pub fn get_daily_challenge(&mut self) -> Challenge {
86        let today = Utc::now().date_naive();
87
88        // Check if we need to generate a new daily challenge
89        if self.last_daily_generated != Some(today) {
90            self.daily_challenge = Some(self.generate_daily_challenge(today));
91            self.last_daily_generated = Some(today);
92        }
93
94        self.daily_challenge.clone().unwrap()
95    }
96
97    /// Get or generate this week's weekly challenge
98    pub fn get_weekly_challenge(&mut self) -> Challenge {
99        let now = Utc::now();
100        let current_week = now.iso_week().week();
101
102        // Check if we need to generate a new weekly challenge
103        if self.last_weekly_generated != Some(current_week) {
104            self.weekly_challenge = Some(self.generate_weekly_challenge(current_week));
105            self.last_weekly_generated = Some(current_week);
106            self.weekly_step_progress = 0;
107        }
108
109        self.weekly_challenge.clone().unwrap()
110    }
111
112    /// Mark a challenge as completed
113    ///
114    /// Note: this only updates the manager's own bookkeeping (used by
115    /// `is_daily_completed` / `is_weekly_completed` UI helpers). For
116    /// achievement purposes `UserStats` is the single source of truth —
117    /// prefer `check_command`, which keeps both in sync.
118    pub fn complete_challenge(&mut self, challenge_id: String) {
119        self.completed_challenges.insert(challenge_id);
120    }
121
122    /// Validation entry point the TUI should call after every executed shell
123    /// command.
124    ///
125    /// Checks the command against the currently active daily and weekly
126    /// challenges (as previously generated by `get_daily_challenge` /
127    /// `get_weekly_challenge`), advancing multi-step weekly-scenario
128    /// progress as steps are matched. When a challenge is completed, the
129    /// completion is recorded in this manager AND routed into `stats`
130    /// (`UserStats::complete_challenge` + the `"first_challenge"` event), so
131    /// UserStats remains the single source of truth for achievements.
132    ///
133    /// Returns the challenge the command just completed, if any. Call
134    /// `stats.check_achievements()` afterwards (or use the achievements
135    /// already returned by the routed `record_event`) to surface unlocks.
136    pub fn check_command(&mut self, command: &str, stats: &mut UserStats) -> Option<Challenge> {
137        let completed = self.match_command(command)?;
138
139        self.complete_challenge(completed.id.clone());
140        stats.complete_challenge(completed.id.clone());
141        stats.record_event("first_challenge");
142
143        Some(completed)
144    }
145
146    /// Find the active, not-yet-completed challenge that `command` satisfies,
147    /// advancing weekly-scenario step progress as a side effect.
148    fn match_command(&mut self, command: &str) -> Option<Challenge> {
149        let validator = LessonValidator::new();
150
151        // Daily challenge: a single matching command completes it
152        if let Some(daily) = &self.daily_challenge {
153            if !self.is_challenge_completed(&daily.id)
154                && Self::command_satisfies(&validator, daily, command)
155            {
156                return Some(daily.clone());
157            }
158        }
159
160        // Weekly scenario: commands must satisfy each step in order
161        if let Some(weekly) = self.weekly_challenge.clone() {
162            if !self.is_challenge_completed(&weekly.id) {
163                if let ChallengeType::WeeklyScenario { steps, .. } = &weekly.challenge_type {
164                    if let Some(step) = steps.get(self.weekly_step_progress) {
165                        // Scenario steps carry only a validation (typically
166                        // Regex), so there is no expected command string.
167                        if validator
168                            .validate_command(command, "", &step.validation)
169                            .is_success()
170                        {
171                            self.weekly_step_progress += 1;
172                            if self.weekly_step_progress >= steps.len() {
173                                return Some(weekly);
174                            }
175                        }
176                    }
177                }
178            }
179        }
180
181        None
182    }
183
184    /// Check whether a single command satisfies a single-command challenge
185    fn command_satisfies(validator: &LessonValidator, challenge: &Challenge, command: &str) -> bool {
186        match &challenge.challenge_type {
187            ChallengeType::DailyCommand {
188                command: expected,
189                validation,
190                ..
191            } => validator.validate_command(command, expected, validation).is_success(),
192            ChallengeType::SpeedChallenge { commands, .. } => {
193                let normalize = |s: &str| s.split_whitespace().collect::<Vec<_>>().join(" ");
194                let input = normalize(command);
195                commands.iter().any(|c| normalize(c) == input)
196            }
197            // Multi-step scenarios are handled step-by-step in match_command
198            ChallengeType::WeeklyScenario { .. } => false,
199        }
200    }
201
202    /// Check if a challenge has been completed
203    pub fn is_challenge_completed(&self, challenge_id: &str) -> bool {
204        self.completed_challenges.contains(challenge_id)
205    }
206
207    /// Generate a daily challenge based on the date
208    fn generate_daily_challenge(&self, date: chrono::NaiveDate) -> Challenge {
209        let all_daily = all_daily_challenges();
210        // Use day of year to deterministically select a challenge
211        let index = (date.ordinal0() as usize) % all_daily.len();
212        all_daily[index].clone()
213    }
214
215    /// Generate a weekly challenge based on the week number
216    fn generate_weekly_challenge(&self, week: u32) -> Challenge {
217        let all_weekly = all_weekly_challenges();
218        // Use week number to deterministically select a challenge
219        let index = (week as usize - 1) % all_weekly.len();
220        all_weekly[index].clone()
221    }
222
223    /// Get all challenges completed this week
224    pub fn weekly_completions(&self) -> usize {
225        // This could be enhanced to track completion timestamps
226        // For now, returns total completed challenges
227        self.completed_challenges.len()
228    }
229
230    /// Check if today's daily challenge is completed
231    pub fn is_daily_completed(&self) -> bool {
232        if let Some(ref daily) = self.daily_challenge {
233            self.is_challenge_completed(&daily.id)
234        } else {
235            false
236        }
237    }
238
239    /// Check if this week's weekly challenge is completed
240    pub fn is_weekly_completed(&self) -> bool {
241        if let Some(ref weekly) = self.weekly_challenge {
242            self.is_challenge_completed(&weekly.id)
243        } else {
244            false
245        }
246    }
247}
248
249impl Default for ChallengeManager {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255/// Get all available daily challenges
256pub fn all_daily_challenges() -> Vec<Challenge> {
257    vec![
258        Challenge {
259            id: "daily_grep_recursive".to_string(),
260            title: "Master Recursive Search".to_string(),
261            description: "Use grep with the -r flag to search for 'error' in all files within a directory.".to_string(),
262            difficulty: Difficulty::Intermediate,
263            challenge_type: ChallengeType::DailyCommand {
264                command: "grep -r 'error' /var/log".to_string(),
265                task_description: "Search recursively for the word 'error' in log files".to_string(),
266                validation: CommandValidation::CommandAndFlags,
267            },
268            points: 25,
269        },
270        Challenge {
271            id: "daily_chmod_executable".to_string(),
272            title: "Make It Executable".to_string(),
273            description: "Use chmod to make a script file executable by adding execute permissions.".to_string(),
274            difficulty: Difficulty::Beginner,
275            challenge_type: ChallengeType::DailyCommand {
276                command: "chmod +x script.sh".to_string(),
277                task_description: "Make a file executable using chmod".to_string(),
278                validation: CommandValidation::CommandAndFlags,
279            },
280            points: 20,
281        },
282        Challenge {
283            id: "daily_find_by_name".to_string(),
284            title: "Find by Name".to_string(),
285            description: "Use the find command to locate all .txt files in your home directory.".to_string(),
286            difficulty: Difficulty::Intermediate,
287            challenge_type: ChallengeType::DailyCommand {
288                command: "find ~ -name '*.txt'".to_string(),
289                task_description: "Find all text files in home directory".to_string(),
290                validation: CommandValidation::CommandAndFlags,
291            },
292            points: 25,
293        },
294        Challenge {
295            id: "daily_pipe_mastery".to_string(),
296            title: "Pipe Master".to_string(),
297            description: "Combine ls and grep using a pipe to find files containing 'test' in their names.".to_string(),
298            difficulty: Difficulty::Beginner,
299            challenge_type: ChallengeType::DailyCommand {
300                command: "ls | grep test".to_string(),
301                task_description: "Use pipes to filter ls output with grep".to_string(),
302                validation: CommandValidation::CommandAndFlags,
303            },
304            points: 20,
305        },
306        Challenge {
307            id: "daily_tar_archive".to_string(),
308            title: "Archive Creation".to_string(),
309            description: "Create a compressed tar archive of a directory using tar -czf.".to_string(),
310            difficulty: Difficulty::Intermediate,
311            challenge_type: ChallengeType::DailyCommand {
312                command: "tar -czf archive.tar.gz directory/".to_string(),
313                task_description: "Create a compressed tar archive".to_string(),
314                validation: CommandValidation::CommandAndFlags,
315            },
316            points: 30,
317        },
318        Challenge {
319            id: "daily_curl_download".to_string(),
320            title: "Web Fetcher".to_string(),
321            description: "Use curl to download a file from a URL and save it with -o option.".to_string(),
322            difficulty: Difficulty::Intermediate,
323            challenge_type: ChallengeType::DailyCommand {
324                command: "curl -o output.html https://example.com".to_string(),
325                task_description: "Download a file using curl".to_string(),
326                validation: CommandValidation::CommandAndFlags,
327            },
328            points: 25,
329        },
330        Challenge {
331            id: "daily_process_check".to_string(),
332            title: "Process Inspector".to_string(),
333            description: "Use ps aux combined with grep to find a specific running process.".to_string(),
334            difficulty: Difficulty::Intermediate,
335            challenge_type: ChallengeType::DailyCommand {
336                command: "ps aux | grep firefox".to_string(),
337                task_description: "Find a running process by name".to_string(),
338                validation: CommandValidation::CommandAndFlags,
339            },
340            points: 25,
341        },
342        Challenge {
343            id: "daily_disk_usage".to_string(),
344            title: "Disk Usage Detective".to_string(),
345            description: "Use du -sh to find the size of a directory in human-readable format.".to_string(),
346            difficulty: Difficulty::Beginner,
347            challenge_type: ChallengeType::DailyCommand {
348                command: "du -sh /var/log".to_string(),
349                task_description: "Check directory size with du".to_string(),
350                validation: CommandValidation::CommandAndFlags,
351            },
352            points: 20,
353        },
354        Challenge {
355            id: "daily_sed_replace".to_string(),
356            title: "Text Transformer".to_string(),
357            description: "Use sed to replace all occurrences of a word in a file.".to_string(),
358            difficulty: Difficulty::Advanced,
359            challenge_type: ChallengeType::DailyCommand {
360                command: "sed -i 's/old/new/g' file.txt".to_string(),
361                task_description: "Replace text in a file using sed".to_string(),
362                validation: CommandValidation::CommandAndFlags,
363            },
364            points: 35,
365        },
366        Challenge {
367            id: "daily_symlink_create".to_string(),
368            title: "Link Creator".to_string(),
369            description: "Create a symbolic link using ln -s pointing to a target file.".to_string(),
370            difficulty: Difficulty::Beginner,
371            challenge_type: ChallengeType::DailyCommand {
372                command: "ln -s /path/to/target linkname".to_string(),
373                task_description: "Create a symbolic link".to_string(),
374                validation: CommandValidation::CommandAndFlags,
375            },
376            points: 20,
377        },
378        Challenge {
379            id: "daily_history_search".to_string(),
380            title: "History Hunter".to_string(),
381            description: "Use history with grep to find commands you've run in the past.".to_string(),
382            difficulty: Difficulty::Beginner,
383            challenge_type: ChallengeType::DailyCommand {
384                command: "history | grep git".to_string(),
385                task_description: "Search command history".to_string(),
386                validation: CommandValidation::CommandAndFlags,
387            },
388            points: 15,
389        },
390        Challenge {
391            id: "daily_wc_count".to_string(),
392            title: "Word Counter".to_string(),
393            description: "Count the number of lines in a file using wc -l.".to_string(),
394            difficulty: Difficulty::Beginner,
395            challenge_type: ChallengeType::DailyCommand {
396                command: "wc -l file.txt".to_string(),
397                task_description: "Count lines in a file".to_string(),
398                validation: CommandValidation::CommandAndFlags,
399            },
400            points: 15,
401        },
402        Challenge {
403            id: "daily_env_check".to_string(),
404            title: "Environment Explorer".to_string(),
405            description: "Use env or printenv to display all environment variables.".to_string(),
406            difficulty: Difficulty::Beginner,
407            challenge_type: ChallengeType::DailyCommand {
408                command: "env".to_string(),
409                task_description: "Display environment variables".to_string(),
410                validation: CommandValidation::CommandOnly,
411            },
412            points: 15,
413        },
414        Challenge {
415            id: "daily_tail_follow".to_string(),
416            title: "Log Follower".to_string(),
417            description: "Use tail -f to continuously monitor a log file in real-time.".to_string(),
418            difficulty: Difficulty::Intermediate,
419            challenge_type: ChallengeType::DailyCommand {
420                command: "tail -f /var/log/syslog".to_string(),
421                task_description: "Follow a log file in real-time".to_string(),
422                validation: CommandValidation::CommandAndFlags,
423            },
424            points: 25,
425        },
426        Challenge {
427            id: "daily_awk_column".to_string(),
428            title: "Column Extractor".to_string(),
429            description: "Use awk to extract a specific column from formatted text output.".to_string(),
430            difficulty: Difficulty::Advanced,
431            challenge_type: ChallengeType::DailyCommand {
432                command: "ps aux | awk '{print $1, $11}'".to_string(),
433                task_description: "Extract specific columns using awk".to_string(),
434                validation: CommandValidation::CommandAndFlags,
435            },
436            points: 35,
437        },
438    ]
439}
440
441/// Get all available weekly challenges
442pub fn all_weekly_challenges() -> Vec<Challenge> {
443    vec![
444        Challenge {
445            id: "weekly_debug_server".to_string(),
446            title: "Debug the Broken Server".to_string(),
447            description: "A simulated web server isn't starting. Use diagnostic commands to find and fix the issues.".to_string(),
448            difficulty: Difficulty::Advanced,
449            challenge_type: ChallengeType::WeeklyScenario {
450                scenario: "The web server on port 8080 isn't responding. Debug the issue step by step.".to_string(),
451                steps: vec![
452                    ChallengeStep {
453                        description: "Check if the server process is running".to_string(),
454                        validation: CommandValidation::Regex("ps.*8080|netstat.*8080|lsof.*8080".to_string()),
455                        hint: Some("Try 'ps aux | grep server' or 'netstat -tulpn'".to_string()),
456                    },
457                    ChallengeStep {
458                        description: "Check the server log files for errors".to_string(),
459                        validation: CommandValidation::Regex("tail.*log|cat.*log|less.*log".to_string()),
460                        hint: Some("Look in /var/log/ or check application-specific logs".to_string()),
461                    },
462                    ChallengeStep {
463                        description: "Verify the port is not in use by another process".to_string(),
464                        validation: CommandValidation::Regex("netstat.*8080|lsof.*8080|ss.*8080".to_string()),
465                        hint: Some("Use 'lsof -i :8080' or 'netstat -tulpn | grep 8080'".to_string()),
466                    },
467                ],
468            },
469            points: 100,
470        },
471        Challenge {
472            id: "weekly_organize_files".to_string(),
473            title: "Organize the Chaos".to_string(),
474            description: "A directory is filled with mixed file types. Organize them into proper subdirectories.".to_string(),
475            difficulty: Difficulty::Intermediate,
476            challenge_type: ChallengeType::WeeklyScenario {
477                scenario: "The downloads folder has 100+ files of different types all mixed together. Organize them!".to_string(),
478                steps: vec![
479                    ChallengeStep {
480                        description: "Create directories for different file types (images, documents, code)".to_string(),
481                        validation: CommandValidation::Regex("mkdir.*images|mkdir.*documents|mkdir.*code".to_string()),
482                        hint: Some("Use 'mkdir -p' to create multiple directories".to_string()),
483                    },
484                    ChallengeStep {
485                        description: "Find and move all image files to the images directory".to_string(),
486                        validation: CommandValidation::Regex("mv.*\\.(jpg|png|gif)|find.*\\.(jpg|png).*-exec mv".to_string()),
487                        hint: Some("Use 'find' with '-name' or a for loop with 'mv'".to_string()),
488                    },
489                    ChallengeStep {
490                        description: "Create an archive of the organized directories".to_string(),
491                        validation: CommandValidation::Regex("tar.*czf|zip.*-r".to_string()),
492                        hint: Some("Use 'tar -czf organized.tar.gz' or 'zip -r'".to_string()),
493                    },
494                ],
495            },
496            points: 80,
497        },
498        Challenge {
499            id: "weekly_security_audit".to_string(),
500            title: "Security Audit".to_string(),
501            description: "Perform a basic security audit by checking file permissions and user access.".to_string(),
502            difficulty: Difficulty::Advanced,
503            challenge_type: ChallengeType::WeeklyScenario {
504                scenario: "Audit file permissions and find potential security issues in a system directory.".to_string(),
505                steps: vec![
506                    ChallengeStep {
507                        description: "Find all world-writable files".to_string(),
508                        validation: CommandValidation::Regex("find.*-perm.*777|find.*-perm.*o=w".to_string()),
509                        hint: Some("Use 'find /path -perm -002'".to_string()),
510                    },
511                    ChallengeStep {
512                        description: "List all users who can access the system".to_string(),
513                        validation: CommandValidation::Regex("cat /etc/passwd|getent passwd|cut.*passwd".to_string()),
514                        hint: Some("Check /etc/passwd or use 'getent passwd'".to_string()),
515                    },
516                    ChallengeStep {
517                        description: "Check for files with setuid bit enabled".to_string(),
518                        validation: CommandValidation::Regex("find.*-perm.*4000|find.*-perm.*u=s".to_string()),
519                        hint: Some("Use 'find / -perm -4000'".to_string()),
520                    },
521                ],
522            },
523            points: 120,
524        },
525        Challenge {
526            id: "weekly_backup_strategy".to_string(),
527            title: "Backup Strategy".to_string(),
528            description: "Create a comprehensive backup of important files with proper organization.".to_string(),
529            difficulty: Difficulty::Intermediate,
530            challenge_type: ChallengeType::WeeklyScenario {
531                scenario: "Set up an automated backup system for critical directories.".to_string(),
532                steps: vec![
533                    ChallengeStep {
534                        description: "Create a timestamped backup directory".to_string(),
535                        validation: CommandValidation::Regex("mkdir.*backup.*$(date|date.*\\+)".to_string()),
536                        hint: Some("Use 'mkdir backup-$(date +%Y%m%d)'".to_string()),
537                    },
538                    ChallengeStep {
539                        description: "Copy important files preserving permissions and timestamps".to_string(),
540                        validation: CommandValidation::Regex("cp -a|cp.*-p|rsync.*-a".to_string()),
541                        hint: Some("Use 'cp -a' or 'rsync -av'".to_string()),
542                    },
543                    ChallengeStep {
544                        description: "Create a compressed archive of the backup".to_string(),
545                        validation: CommandValidation::Regex("tar.*czf|gzip|zip".to_string()),
546                        hint: Some("Use 'tar -czf backup.tar.gz backup/'".to_string()),
547                    },
548                ],
549            },
550            points: 90,
551        },
552        Challenge {
553            id: "weekly_log_analysis".to_string(),
554            title: "Log Analysis Detective".to_string(),
555            description: "Analyze system logs to find errors, warnings, and unusual patterns.".to_string(),
556            difficulty: Difficulty::Advanced,
557            challenge_type: ChallengeType::WeeklyScenario {
558                scenario: "Investigate system logs to identify and report issues.".to_string(),
559                steps: vec![
560                    ChallengeStep {
561                        description: "Find all error messages in system logs".to_string(),
562                        validation: CommandValidation::Regex("grep.*error|grep.*ERROR|egrep.*'error|ERROR'".to_string()),
563                        hint: Some("Use 'grep -i error /var/log/syslog'".to_string()),
564                    },
565                    ChallengeStep {
566                        description: "Count occurrences of different error types".to_string(),
567                        validation: CommandValidation::Regex("sort|uniq -c|wc".to_string()),
568                        hint: Some("Combine 'sort | uniq -c | sort -n'".to_string()),
569                    },
570                    ChallengeStep {
571                        description: "Extract and save the top 10 most frequent errors to a file".to_string(),
572                        validation: CommandValidation::Regex("head.*>|>.*head|tee".to_string()),
573                        hint: Some("Use pipes and redirect with '> errors.txt'".to_string()),
574                    },
575                ],
576            },
577            points: 110,
578        },
579    ]
580}
581
582/// Get all available speed challenges
583pub fn all_speed_challenges() -> Vec<Challenge> {
584    vec![
585        Challenge {
586            id: "speed_cleanup_tmp".to_string(),
587            title: "Rapid Cleanup".to_string(),
588            description: "Find and delete all .tmp files in a directory tree within 60 seconds.".to_string(),
589            difficulty: Difficulty::Intermediate,
590            challenge_type: ChallengeType::SpeedChallenge {
591                task: "Remove all temporary files (.tmp extension) from the current directory and subdirectories".to_string(),
592                time_limit_seconds: 60,
593                commands: vec![
594                    "find . -name '*.tmp' -delete".to_string(),
595                    "find . -name '*.tmp' -exec rm {} \\;".to_string(),
596                ],
597            },
598            points: 50,
599        },
600        Challenge {
601            id: "speed_count_files".to_string(),
602            title: "Quick Count".to_string(),
603            description: "Count all files in a directory within 30 seconds.".to_string(),
604            difficulty: Difficulty::Beginner,
605            challenge_type: ChallengeType::SpeedChallenge {
606                task: "Count the total number of files (not directories) in the current directory tree".to_string(),
607                time_limit_seconds: 30,
608                commands: vec![
609                    "find . -type f | wc -l".to_string(),
610                    "ls -lR | grep '^-' | wc -l".to_string(),
611                ],
612            },
613            points: 40,
614        },
615        Challenge {
616            id: "speed_largest_files".to_string(),
617            title: "Size Hunter".to_string(),
618            description: "Find the 5 largest files in a directory within 45 seconds.".to_string(),
619            difficulty: Difficulty::Intermediate,
620            challenge_type: ChallengeType::SpeedChallenge {
621                task: "List the 5 largest files in the directory tree with their sizes".to_string(),
622                time_limit_seconds: 45,
623                commands: vec![
624                    "find . -type f -exec ls -lh {} \\; | sort -k5 -hr | head -5".to_string(),
625                    "du -ah . | sort -rh | head -5".to_string(),
626                ],
627            },
628            points: 55,
629        },
630    ]
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636
637    #[test]
638    fn test_challenge_manager_creation() {
639        let manager = ChallengeManager::new();
640        assert!(manager.daily_challenge.is_none());
641        assert!(manager.weekly_challenge.is_none());
642        assert_eq!(manager.completed_challenges.len(), 0);
643    }
644
645    #[test]
646    fn test_daily_challenge_generation() {
647        let mut manager = ChallengeManager::new();
648        let challenge = manager.get_daily_challenge();
649        assert!(!challenge.id.is_empty());
650        assert!(!challenge.title.is_empty());
651        assert!(challenge.points > 0);
652    }
653
654    #[test]
655    fn test_weekly_challenge_generation() {
656        let mut manager = ChallengeManager::new();
657        let challenge = manager.get_weekly_challenge();
658        assert!(!challenge.id.is_empty());
659        assert!(!challenge.title.is_empty());
660        assert!(challenge.points > 0);
661    }
662
663    #[test]
664    fn test_challenge_completion() {
665        let mut manager = ChallengeManager::new();
666        assert!(!manager.is_challenge_completed("test_challenge"));
667
668        manager.complete_challenge("test_challenge".to_string());
669        assert!(manager.is_challenge_completed("test_challenge"));
670    }
671
672    #[test]
673    fn test_daily_challenges_exist() {
674        let challenges = all_daily_challenges();
675        assert!(!challenges.is_empty());
676        assert!(challenges.len() >= 10);
677    }
678
679    #[test]
680    fn test_weekly_challenges_exist() {
681        let challenges = all_weekly_challenges();
682        assert!(!challenges.is_empty());
683        assert!(challenges.len() >= 5);
684    }
685
686    #[test]
687    fn test_deterministic_daily_selection() {
688        let mut manager1 = ChallengeManager::new();
689        let mut manager2 = ChallengeManager::new();
690
691        let challenge1 = manager1.get_daily_challenge();
692        let challenge2 = manager2.get_daily_challenge();
693
694        // Same day should give same challenge
695        assert_eq!(challenge1.id, challenge2.id);
696    }
697
698    #[test]
699    fn test_check_command_completes_daily_challenge() {
700        let mut manager = ChallengeManager::new();
701        let mut stats = UserStats::new();
702        let daily = manager.get_daily_challenge();
703
704        // A non-matching command completes nothing
705        assert!(manager.check_command("echo hello", &mut stats).is_none());
706        assert!(!manager.is_daily_completed());
707
708        // The challenge's own expected command satisfies it
709        let expected = match &daily.challenge_type {
710            ChallengeType::DailyCommand { command, .. } => command.clone(),
711            other => panic!("daily challenge should be DailyCommand, got {:?}", other),
712        };
713        let completed = manager.check_command(&expected, &mut stats).unwrap();
714        assert_eq!(completed.id, daily.id);
715        assert!(manager.is_daily_completed());
716
717        // UserStats is kept in sync (single source of truth for achievements)
718        assert!(stats.completed_challenges.contains(&daily.id));
719        assert!(stats.completed_challenges.contains("first_challenge"));
720        assert!(stats.achievements.is_unlocked("challenge_accepted"));
721
722        // Already-completed challenges don't complete again
723        assert!(manager.check_command(&expected, &mut stats).is_none());
724    }
725
726    #[test]
727    fn test_check_command_weekly_scenario_steps() {
728        let mut manager = ChallengeManager::new();
729        let mut stats = UserStats::new();
730
731        // Pin a known weekly scenario (Debug the Broken Server)
732        let weekly = all_weekly_challenges()[0].clone();
733        assert_eq!(weekly.id, "weekly_debug_server");
734        manager.weekly_challenge = Some(weekly.clone());
735
736        // Step 1: check if the server process is running
737        assert!(manager.check_command("ps aux | grep 8080", &mut stats).is_none());
738        // Step 2: check the log files
739        assert!(manager.check_command("tail -f /var/log/server.log", &mut stats).is_none());
740        // A command that doesn't match the current step doesn't advance
741        assert!(manager.check_command("echo not a match", &mut stats).is_none());
742        // Step 3 (final): verify the port
743        let completed = manager.check_command("lsof -i :8080", &mut stats).unwrap();
744        assert_eq!(completed.id, weekly.id);
745        assert!(manager.is_weekly_completed());
746        assert!(stats.completed_challenges.contains(&weekly.id));
747    }
748}