Skip to main content

nika_cli/
course.rs

1//! Course subcommand handler — interactive learning CLI
2//!
3//! 8 subcommands: status, next, check, hint, reset, run, info, watch
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7use std::time::SystemTime;
8
9use clap::Subcommand;
10use colored::Colorize;
11
12use nika_engine::error::NikaError;
13use nika_engine::init::course::{
14    checks::{
15        check_has_depends_on, check_has_schema, check_has_verb, check_has_with_bindings,
16        check_min_tasks, check_no_todos, CheckVerdict, ExerciseReport, LevelReport,
17    },
18    exercises,
19    hints::{get_hints, next_hint_level},
20    levels::{self, Level, LEVELS},
21    progress::{CourseProgress, ExerciseStatus, LevelStatus},
22};
23
24/// Course subcommand actions
25#[derive(Subcommand)]
26pub enum CourseAction {
27    /// Show course progress — constellation map
28    Status,
29    /// Show the next exercise to work on
30    Next,
31    /// Check an exercise or level (validates YAML)
32    Check {
33        /// Level number, slug, or name (e.g., "1", "jailbreak", "Jailbreak")
34        level: Option<String>,
35    },
36    /// Show progressive hints for an exercise
37    Hint {
38        /// Exercise ID like "01-03" (level-exercise)
39        exercise: Option<String>,
40    },
41    /// Reset a level to start over
42    Reset {
43        /// Level number, slug, or name
44        level: String,
45    },
46    /// Run a course exercise workflow
47    Run {
48        /// Exercise ID like "01-03" (level-exercise)
49        exercise: String,
50    },
51    /// Show detailed info about a level or the whole course
52    Info {
53        /// Level number, slug, or name (omit for overview)
54        level: Option<String>,
55    },
56    /// Watch exercise files and auto-check on save
57    Watch,
58}
59
60/// Entry point for `nika course <action>`
61pub fn handle_course_command(action: CourseAction) -> Result<(), NikaError> {
62    match action {
63        CourseAction::Status => cmd_status(),
64        CourseAction::Next => cmd_next(),
65        CourseAction::Check { level } => cmd_check(level),
66        CourseAction::Hint { exercise } => cmd_hint(exercise),
67        CourseAction::Reset { level } => cmd_reset(&level),
68        CourseAction::Run { exercise } => cmd_run(&exercise),
69        CourseAction::Info { level } => cmd_info(level),
70        CourseAction::Watch => cmd_watch(),
71    }
72}
73
74// ─── Course root discovery ──────────────────────────────────────────────────
75
76/// Progress file path relative to course root
77const PROGRESS_FILE: &str = ".nika/course-progress.toml";
78
79/// Find the course root by walking up from cwd looking for
80/// `.nika/course-progress.toml` or a `course/` level directory.
81fn find_course_root() -> Result<PathBuf, NikaError> {
82    let cwd = std::env::current_dir()?;
83    let mut dir = cwd.as_path();
84
85    loop {
86        // Check for progress file
87        if dir.join(PROGRESS_FILE).exists() {
88            return Ok(dir.to_path_buf());
89        }
90        // Check for course level directories (e.g., 01-jailbreak/)
91        if dir.join("01-jailbreak").is_dir() {
92            return Ok(dir.to_path_buf());
93        }
94        // Check for .nika/ directory (project root)
95        if dir.join(".nika").is_dir() {
96            return Ok(dir.to_path_buf());
97        }
98        match dir.parent() {
99            Some(parent) => dir = parent,
100            None => {
101                return Err(NikaError::ValidationError {
102                    reason: "Not inside a Nika project. Run `nika init` first.".to_string(),
103                });
104            }
105        }
106    }
107}
108
109/// Load or initialize course progress
110fn load_progress(root: &Path) -> Result<CourseProgress, NikaError> {
111    let path = root.join(PROGRESS_FILE);
112    if path.exists() {
113        Ok(CourseProgress::load(&path)?)
114    } else {
115        Ok(CourseProgress::new_course())
116    }
117}
118
119/// Save course progress
120fn save_progress(root: &Path, progress: &mut CourseProgress) -> Result<(), NikaError> {
121    let path = root.join(PROGRESS_FILE);
122    Ok(progress.save(&path)?)
123}
124
125// ─── Level resolution ───────────────────────────────────────────────────────
126
127/// Resolve a user-provided level identifier to a Level.
128///
129/// Accepts: number ("1", "01"), slug ("jailbreak"), or name ("Jailbreak").
130pub fn resolve_level(input: &str) -> Result<&'static Level, NikaError> {
131    // Try as number
132    if let Ok(n) = input.parse::<u8>() {
133        if let Some(level) = levels::by_number(n) {
134            return Ok(level);
135        }
136    }
137
138    // Try as zero-padded number (e.g., "01")
139    let trimmed = input.trim_start_matches('0');
140    if !trimmed.is_empty() {
141        if let Ok(n) = trimmed.parse::<u8>() {
142            if let Some(level) = levels::by_number(n) {
143                return Ok(level);
144            }
145        }
146    }
147
148    // Try as slug (case-insensitive)
149    let lower = input.to_lowercase();
150    if let Some(level) = levels::by_slug(&lower) {
151        return Ok(level);
152    }
153
154    // Try as name (case-insensitive)
155    if let Some(level) = LEVELS.iter().find(|l| l.name.to_lowercase() == lower) {
156        return Ok(level);
157    }
158
159    Err(NikaError::ValidationError {
160        reason: format!(
161            "Unknown level '{}'. Use a number (1-12), slug, or name. Try: nika course info",
162            input
163        ),
164    })
165}
166
167/// Parse an exercise ID like "01-03" -> (level_num, exercise_num)
168fn parse_exercise_id(id: &str) -> Result<(u8, u8), NikaError> {
169    let parts: Vec<&str> = id.split('-').collect();
170    if parts.len() != 2 {
171        return Err(NikaError::ValidationError {
172            reason: format!(
173                "Invalid exercise ID '{}'. Use format: LL-EE (e.g., 01-03)",
174                id
175            ),
176        });
177    }
178    let level: u8 = parts[0].parse().map_err(|_| NikaError::ValidationError {
179        reason: format!("Invalid level number in '{}'. Expected: LL-EE", id),
180    })?;
181    let exercise: u8 = parts[1].parse().map_err(|_| NikaError::ValidationError {
182        reason: format!("Invalid exercise number in '{}'. Expected: LL-EE", id),
183    })?;
184
185    // Validate level exists
186    let level_def = levels::by_number(level).ok_or_else(|| NikaError::ValidationError {
187        reason: format!("Level {} does not exist. Valid: 1-12", level),
188    })?;
189
190    // Validate exercise within range
191    if exercise < 1 || exercise > level_def.exercise_count {
192        return Err(NikaError::ValidationError {
193            reason: format!(
194                "Exercise {} out of range for level {} ({}). Valid: 1-{}",
195                exercise, level, level_def.name, level_def.exercise_count
196            ),
197        });
198    }
199
200    Ok((level, exercise))
201}
202
203/// Find the exercise workflow file path.
204///
205/// Uses the embedded exercise data to resolve the actual filename
206/// (e.g., `01-hello-world.nika.yaml`), falling back to a glob of the
207/// level directory when the exercise is not found in the embedded data.
208fn exercise_path(root: &Path, level: u8, exercise: u8) -> Result<PathBuf, NikaError> {
209    let level_def = levels::by_number(level).ok_or_else(|| NikaError::CourseNotFound {
210        path: format!("level {}", level),
211    })?;
212    let level_dir = root.join(format!("{:02}-{}", level, level_def.slug));
213
214    // Look up the real filename from the embedded exercise data
215    if let Some(ex) = exercises::all_exercises()
216        .into_iter()
217        .find(|e| e.level_slug == level_def.slug && e.exercise_num == exercise)
218    {
219        return Ok(level_dir.join(ex.filename));
220    }
221
222    // Fallback: list *.nika.yaml files sorted and pick the Nth one
223    if let Ok(entries) = std::fs::read_dir(&level_dir) {
224        let mut files: Vec<PathBuf> = entries
225            .filter_map(|e| e.ok())
226            .map(|e| e.path())
227            .filter(|p| {
228                p.extension().is_some_and(|ext| ext == "yaml")
229                    && p.to_string_lossy().ends_with(".nika.yaml")
230            })
231            .collect();
232        files.sort();
233        if let Some(path) = files.into_iter().nth(exercise as usize - 1) {
234            return Ok(path);
235        }
236    }
237
238    // Last resort: use the level_dir with a synthetic name so errors are clear
239    Ok(level_dir.join(format!("{:02}-exercise-{}.nika.yaml", exercise, exercise)))
240}
241
242// ─── Status icons ───────────────────────────────────────────────────────────
243
244fn exercise_status_icon(status: &ExerciseStatus) -> &'static str {
245    match status {
246        ExerciseStatus::NotStarted => "  ",
247        ExerciseStatus::Attempted => "  ",
248        ExerciseStatus::Passed => "  ",
249        ExerciseStatus::Perfect => "  ",
250    }
251}
252
253// ─── Command implementations ────────────────────────────────────────────────
254
255/// nika course status -- enhanced constellation map
256fn cmd_status() -> Result<(), NikaError> {
257    let root = find_course_root()?;
258    let progress = load_progress(&root)?;
259
260    let completed_levels = progress.completed_levels();
261    let total_levels = LEVELS.len();
262    let completed_ex = progress.completed_exercises();
263    let total_ex = levels::total_exercises();
264
265    println!();
266    println!(
267        "  {}",
268        "Nika Course -- Your Liberation Journey".cyan().bold()
269    );
270    println!();
271
272    let level_statuses: Vec<(&Level, LevelStatus)> = LEVELS
273        .iter()
274        .map(|l| {
275            let key = l.number.to_string();
276            let status = progress
277                .levels
278                .get(&key)
279                .map(|lp| lp.status.clone())
280                .unwrap_or(LevelStatus::Locked);
281            (l, status)
282        })
283        .collect();
284
285    for row in 0..2 {
286        let start = row * 6;
287        let end = (start + 6).min(level_statuses.len());
288        let slice = &level_statuses[start..end];
289
290        let mut star_line = String::from("  ");
291        for (i, (level, status)) in slice.iter().enumerate() {
292            star_line.push_str(&constellation_star(status, level.boss));
293            if i < slice.len() - 1 {
294                star_line.push_str(&"----".dimmed().to_string());
295            }
296        }
297        println!("{star_line}");
298
299        let mut num_line = String::from("  ");
300        for (i, (level, status)) in slice.iter().enumerate() {
301            let num = format!("{:02}", level.number);
302            let colored_num = match status {
303                LevelStatus::Completed => num.green().to_string(),
304                LevelStatus::InProgress => num.yellow().to_string(),
305                LevelStatus::Unlocked => num.cyan().to_string(),
306                LevelStatus::Locked => num.dimmed().to_string(),
307            };
308            num_line.push_str(&colored_num);
309            if i < slice.len() - 1 {
310                num_line.push_str("    ");
311            }
312        }
313        println!("{num_line}");
314        println!();
315    }
316
317    for (level, status) in &level_statuses {
318        let key = level.number.to_string();
319        let lp = progress.levels.get(&key);
320
321        let ex_done = lp
322            .map(|l| {
323                l.exercises
324                    .values()
325                    .filter(|s| **s == ExerciseStatus::Passed || **s == ExerciseStatus::Perfect)
326                    .count()
327            })
328            .unwrap_or(0);
329
330        let perfect_count = lp
331            .map(|l| {
332                l.exercises
333                    .values()
334                    .filter(|s| **s == ExerciseStatus::Perfect)
335                    .count()
336            })
337            .unwrap_or(0);
338
339        let star = constellation_star(status, level.boss);
340        let name = level.name;
341
342        let pct = if level.exercise_count > 0 {
343            (ex_done as f64 / level.exercise_count as f64 * 100.0) as u8
344        } else {
345            0
346        };
347
348        let stars_str = if *status == LevelStatus::Completed {
349            let total = level.exercise_count as usize;
350            if perfect_count == total {
351                format!("  {}", "***".yellow())
352            } else if perfect_count >= total / 2 {
353                format!("  {}{}", "**".yellow(), "*".dimmed())
354            } else {
355                format!("  {}{}", "*".yellow(), "**".dimmed())
356            }
357        } else {
358            String::new()
359        };
360
361        let line = match status {
362            LevelStatus::Locked => {
363                format!("  {} {:<16}  {}", star, name.dimmed(), "locked".dimmed())
364            }
365            LevelStatus::Unlocked => {
366                format!("  {} {:<16}  {}", star, name.bold(), "ready".cyan())
367            }
368            LevelStatus::InProgress => {
369                format!(
370                    "  {} {:<16}  {:>3}%",
371                    star,
372                    name.yellow().bold(),
373                    pct.to_string().yellow()
374                )
375            }
376            LevelStatus::Completed => {
377                format!(
378                    "  {} {:<16}  {:>3}%{}",
379                    star,
380                    name.green(),
381                    "100".green(),
382                    stars_str
383                )
384            }
385        };
386        println!("{line}");
387
388        if level.boss {
389            println!("                       {}", "BOSS".red().bold());
390        }
391    }
392
393    println!();
394    println!(
395        "  Progress: {}/{} levels | {}/{} exercises",
396        completed_levels.to_string().green(),
397        total_levels,
398        completed_ex.to_string().green(),
399        total_ex,
400    );
401
402    if progress.metadata.total_hints_used > 0 {
403        println!(
404            "  Hints used: {}",
405            progress.metadata.total_hints_used.to_string().yellow()
406        );
407    }
408
409    println!();
410    println!("  {}", "Run `nika course next` to continue.".dimmed());
411    println!();
412
413    Ok(())
414}
415
416/// Constellation star icon for a level based on its status
417fn constellation_star(status: &LevelStatus, boss: bool) -> String {
418    if boss {
419        match status {
420            LevelStatus::Completed => "*".yellow().bold().to_string(),
421            _ => "*".dimmed().to_string(),
422        }
423    } else {
424        match status {
425            LevelStatus::Completed => "*".green().bold().to_string(),
426            LevelStatus::InProgress => "+".yellow().bold().to_string(),
427            LevelStatus::Unlocked => "+".cyan().to_string(),
428            LevelStatus::Locked => "o".dimmed().to_string(),
429        }
430    }
431}
432
433/// nika course next — find next exercise
434fn cmd_next() -> Result<(), NikaError> {
435    let root = find_course_root()?;
436    let progress = load_progress(&root)?;
437
438    // Find first non-completed exercise in an unlocked/in-progress level
439    for level in LEVELS {
440        let key = level.number.to_string();
441        let lp = match progress.levels.get(&key) {
442            Some(lp) => lp,
443            None => continue,
444        };
445
446        match lp.status {
447            LevelStatus::Locked | LevelStatus::Completed => continue,
448            LevelStatus::Unlocked | LevelStatus::InProgress => {}
449        }
450
451        // Find first non-passed exercise
452        for ex in 1..=level.exercise_count {
453            let ex_key = ex.to_string();
454            let status = lp
455                .exercises
456                .get(&ex_key)
457                .unwrap_or(&ExerciseStatus::NotStarted);
458            if *status != ExerciseStatus::Passed && *status != ExerciseStatus::Perfect {
459                let path = exercise_path(&root, level.number, ex)?;
460                println!();
461                println!(
462                    "  {} Level {:02}: {} -- Exercise {}",
463                    ">>".cyan().bold(),
464                    level.number,
465                    level.name.bold(),
466                    ex
467                );
468                println!("  {}", level.description.dimmed());
469                println!();
470
471                if path.exists() {
472                    println!("  File: {}", path.display().to_string().cyan());
473                    println!();
474                    println!("  {}", "Edit the file, then run:".dimmed());
475                    println!("    nika course check {:02}", level.number);
476                } else {
477                    println!(
478                        "  {} Exercise file not found: {}",
479                        "!".yellow().bold(),
480                        path.display()
481                    );
482                    println!(
483                        "  {}",
484                        "The course content may not be generated yet.".dimmed()
485                    );
486                    println!(
487                        "  {}",
488                        "Tip: run `nika init --course` to generate course files.".dimmed()
489                    );
490                }
491                println!();
492                return Ok(());
493            }
494        }
495    }
496
497    // All done!
498    println!();
499    println!(
500        "  {} {}",
501        "***".green().bold(),
502        "You've completed all available exercises!".green().bold()
503    );
504    println!(
505        "  {}",
506        "Run `nika course status` to see your constellation.".dimmed()
507    );
508    println!();
509
510    Ok(())
511}
512
513/// nika course check [level] — validate exercises
514fn cmd_check(level_arg: Option<String>) -> Result<(), NikaError> {
515    let root = find_course_root()?;
516    let mut progress = load_progress(&root)?;
517
518    let level = match level_arg {
519        Some(ref arg) => resolve_level(arg)?,
520        None => {
521            // Auto-detect: check the current in-progress level
522            let current = LEVELS.iter().find(|l| {
523                let key = l.number.to_string();
524                progress
525                    .levels
526                    .get(&key)
527                    .map(|lp| lp.status == LevelStatus::InProgress)
528                    .unwrap_or(false)
529            });
530            match current {
531                Some(l) => l,
532                None => {
533                    return Err(NikaError::ValidationError {
534                        reason: "No level in progress. Specify a level: nika course check <level>"
535                            .to_string(),
536                    });
537                }
538            }
539        }
540    };
541
542    println!();
543    println!(
544        "  {} Checking Level {:02}: {}",
545        ">>>".cyan().bold(),
546        level.number,
547        level.name.bold()
548    );
549    println!();
550
551    let mut report = LevelReport {
552        level: level.number,
553        exercises: Vec::new(),
554    };
555
556    for ex in 1..=level.exercise_count {
557        let path = exercise_path(&root, level.number, ex)?;
558        let ex_id = format!("{:02}-{:02}", level.number, ex);
559
560        if !path.exists() {
561            println!(
562                "  {} {} -- {}",
563                "SKIP".yellow(),
564                ex_id,
565                "file not found".dimmed()
566            );
567            continue;
568        }
569
570        let yaml = std::fs::read_to_string(&path).map_err(|e| NikaError::ValidationError {
571            reason: format!("Failed to read {}: {}", path.display(), e),
572        })?;
573
574        // Run standard checks based on level + exercise
575        let mut checks = build_checks_for_level(level.number, ex, &yaml);
576
577        // QW #4: Run real AST validation (Phase 1 + Phase 2)
578        match nika_engine::ast::parse_analyzed(&yaml) {
579            Ok(_) => checks.push(nika_engine::init::course::checks::CheckResult {
580                name: "nika check (AST)",
581                verdict: CheckVerdict::Pass,
582            }),
583            Err(e) => checks.push(nika_engine::init::course::checks::CheckResult {
584                name: "nika check (AST)",
585                verdict: CheckVerdict::Fail(e.to_string()),
586            }),
587        }
588
589        let ex_report = ExerciseReport {
590            exercise_id: ex_id.clone(),
591            checks,
592        };
593
594        // Display results
595        if ex_report.passed() {
596            println!("  {} {}", "PASS".green().bold(), ex_id);
597            // Update progress
598            progress.mark_exercise_passed(level.number, ex);
599        } else {
600            println!("  {} {}", "FAIL".red().bold(), ex_id);
601            for check in &ex_report.checks {
602                match &check.verdict {
603                    CheckVerdict::Pass => {
604                        println!("    {} {}", "+".green(), check.name);
605                    }
606                    CheckVerdict::Fail(reason) => {
607                        println!("    {} {} -- {}", "x".red(), check.name, reason.dimmed());
608                    }
609                    CheckVerdict::Bonus(msg) => {
610                        println!("    {} {} -- {}", "*".yellow(), check.name, msg.dimmed());
611                    }
612                }
613            }
614        }
615
616        // Count bonuses
617        let bonus = ex_report.bonus_count();
618        if bonus > 0 {
619            println!("    {} {} bonus(es)!", "*".yellow(), bonus);
620        }
621
622        report.exercises.push(ex_report);
623    }
624
625    // Save updated progress
626    save_progress(&root, &mut progress)?;
627
628    // Summary
629    println!();
630    let passed = report.pass_count();
631    let total = report.exercises.len();
632    if report.all_passed() && total > 0 {
633        println!(
634            "  {} Level {:02} complete! ({}/{})",
635            "***".green().bold(),
636            level.number,
637            passed,
638            total
639        );
640        if let Some(next) = levels::by_number(level.number + 1) {
641            println!(
642                "  {} Next: Level {:02} -- {}",
643                ">>".cyan(),
644                next.number,
645                next.name
646            );
647        }
648    } else {
649        println!(
650            "  {}/{} exercises passed",
651            passed.to_string().yellow(),
652            total
653        );
654        println!("  {}", "Tip: use `nika course hint` for help.".dimmed());
655    }
656
657    // QW #6: Star scoring
658    if total > 0 {
659        let star_correctness = report.all_passed();
660        let total_bonuses: usize = report.exercises.iter().map(|e| e.bonus_count()).sum();
661        let star_elegance = total_bonuses > 0;
662        let key = level.number.to_string();
663        let hints_used = progress
664            .levels
665            .get(&key)
666            .map(|lp| lp.hints_used)
667            .unwrap_or(0);
668        let star_no_hints = hints_used == 0;
669
670        let star_count = star_correctness as u8 + star_elegance as u8 + star_no_hints as u8;
671
672        let stars: String = (0..3)
673            .map(|i| {
674                if i < star_count {
675                    '\u{2605}'
676                } else {
677                    '\u{2606}'
678                }
679            })
680            .collect();
681
682        println!();
683        println!(
684            "  Level {:02} {}: {} ({}/3 stars)",
685            level.number,
686            level.name,
687            stars.yellow(),
688            star_count
689        );
690        println!(
691            "  - {} Correctness: {}/{}{}",
692            if star_correctness {
693                "\u{2605}"
694            } else {
695                "\u{2606}"
696            },
697            passed,
698            total,
699            if star_correctness { " pass" } else { "" }
700        );
701        println!(
702            "  - {} Elegance: {}",
703            if star_elegance {
704                "\u{2605}"
705            } else {
706                "\u{2606}"
707            },
708            if star_elegance {
709                format!("{} bonus(es) unlocked", total_bonuses)
710            } else {
711                "no bonus checks passed yet".to_string()
712            }
713        );
714        println!(
715            "  - {} No hints: {}",
716            if star_no_hints {
717                "\u{2605}"
718            } else {
719                "\u{2606}"
720            },
721            if star_no_hints {
722                "solved without hints".to_string()
723            } else {
724                format!("used {} hint(s)", hints_used)
725            }
726        );
727    }
728    println!();
729
730    Ok(())
731}
732
733/// Build check assertions appropriate for a given level and exercise.
734///
735/// Levels 1-5 have per-exercise verb checks because exercises within a level
736/// teach different verbs. Levels 6-12 use broad level-wide checks.
737fn build_checks_for_level(
738    level_num: u8,
739    exercise_num: u8,
740    yaml: &str,
741) -> Vec<nika_engine::init::course::checks::CheckResult> {
742    let mut checks = vec![
743        check_has_schema(yaml),
744        check_no_todos(yaml),
745        check_min_tasks(yaml, 1),
746    ];
747
748    match level_num {
749        1 => {
750            // Level 1 (Jailbreak): exercises teach different verbs
751            match exercise_num {
752                1 => checks.push(check_has_verb(yaml, "infer")),
753                2 => checks.push(check_has_verb(yaml, "exec")),
754                3 => checks.push(check_has_verb(yaml, "fetch")),
755                4 => checks.push(check_has_verb(yaml, "infer")),
756                5 => {
757                    checks.push(check_has_depends_on(yaml));
758                    checks.push(check_min_tasks(yaml, 2));
759                }
760                _ => {}
761            }
762        }
763        2 => {
764            // Level 2 (Hot Wire): all exercises use with: bindings
765            checks.push(check_has_with_bindings(yaml));
766        }
767        3 => {
768            checks.push(check_has_depends_on(yaml));
769            checks.push(check_min_tasks(yaml, 2));
770        }
771        4 => {
772            // Level 4 (Root Access): Ex1 uses infer:, Ex2-3 are pipelines
773            match exercise_num {
774                1 => checks.push(check_has_verb(yaml, "infer")),
775                _ => checks.push(check_min_tasks(yaml, 2)),
776            }
777        }
778        5 => {
779            // Level 5 (Shapeshifter): Ex1 uses infer: (structured), Ex2-3 are artifacts/retry
780            match exercise_num {
781                1 => checks.push(check_has_verb(yaml, "infer")),
782                _ => checks.push(check_min_tasks(yaml, 2)),
783            }
784        }
785        6 => {
786            checks.push(check_has_verb(yaml, "infer"));
787            // structured output checks would go here
788        }
789        7 => {
790            checks.push(check_has_verb(yaml, "invoke"));
791        }
792        8 => {
793            checks.push(check_has_verb(yaml, "agent"));
794        }
795        9 => {
796            checks.push(check_has_verb(yaml, "fetch"));
797        }
798        10 => {
799            checks.push(check_has_verb(yaml, "invoke"));
800        }
801        11 => {
802            checks.push(check_has_verb(yaml, "invoke"));
803        }
804        12 => {
805            // Boss level: everything
806            checks.push(check_has_depends_on(yaml));
807            checks.push(check_has_with_bindings(yaml));
808            checks.push(check_min_tasks(yaml, 3));
809        }
810        _ => {}
811    }
812
813    checks
814}
815
816/// nika course hint [exercise] — progressive hints
817fn cmd_hint(exercise_arg: Option<String>) -> Result<(), NikaError> {
818    let root = find_course_root()?;
819    let mut progress = load_progress(&root)?;
820
821    let (level_num, ex_num) = match exercise_arg {
822        Some(ref id) => parse_exercise_id(id)?,
823        None => {
824            // QW #7: Smart detection -- find first non-passed exercise
825            match find_current_exercise(&progress) {
826                Ok(found) => found,
827                Err(_) => {
828                    println!();
829                    println!(
830                        "  {} {}",
831                        "***".green().bold(),
832                        "All exercises complete! Move to the next level."
833                            .green()
834                            .bold()
835                    );
836                    println!(
837                        "  {}",
838                        "Run `nika course status` to see your constellation.".dimmed()
839                    );
840                    println!();
841                    return Ok(());
842                }
843            }
844        }
845    };
846
847    let level = levels::by_number(level_num).ok_or_else(|| NikaError::CourseNotFound {
848        path: format!("level {}", level_num),
849    })?;
850    let hints = get_hints(level_num, ex_num).ok_or_else(|| NikaError::ValidationError {
851        reason: format!(
852            "No hints available for exercise {:02}-{:02} yet.",
853            level_num, ex_num
854        ),
855    })?;
856
857    // Determine how many hints have been revealed
858    let key = level_num.to_string();
859    let lp = progress.levels.get(&key);
860    let hints_revealed = lp.map(|l| l.hints_used).unwrap_or(0);
861
862    let next_level = next_hint_level(hints_revealed);
863
864    println!();
865    println!(
866        "  {} Level {:02}: {} -- Exercise {}",
867        "?".cyan().bold(),
868        level_num,
869        level.name.bold(),
870        ex_num
871    );
872    println!();
873
874    // Show all previously revealed hints
875    for (hint_level, text) in hints.hints {
876        let shown = match hint_level {
877            nika_engine::init::course::hints::HintLevel::Conceptual => hints_revealed >= 1,
878            nika_engine::init::course::hints::HintLevel::Specific => hints_revealed >= 2,
879            nika_engine::init::course::hints::HintLevel::Solution => hints_revealed >= 3,
880        };
881
882        if shown {
883            println!("  {} [{}]", "*".yellow(), hint_level.label().dimmed());
884            for line in text.lines() {
885                println!("    {}", line);
886            }
887            println!();
888        }
889    }
890
891    // Reveal next hint
892    match next_level {
893        Some(level) => {
894            // Find the hint at this level
895            if let Some((_, text)) = hints.hints.iter().find(|(l, _)| *l == level) {
896                println!("  {} [{}] (new!)", "*".green().bold(), level.label().cyan());
897                for line in text.lines() {
898                    println!("    {}", line);
899                }
900                println!();
901            }
902            // Record the hint
903            progress.record_hint(level_num);
904            save_progress(&root, &mut progress)?;
905
906            let remaining = 3u32.saturating_sub(hints_revealed + 1);
907            if remaining > 0 {
908                println!("  {} {} hint(s) remaining", "i".dimmed(), remaining);
909            } else {
910                println!("  {}", "All hints revealed.".dimmed());
911            }
912        }
913        None => {
914            println!(
915                "  {}",
916                "All hints already revealed for this exercise.".dimmed()
917            );
918        }
919    }
920    println!();
921
922    Ok(())
923}
924
925/// nika course reset <level> — reset a level
926fn cmd_reset(level_arg: &str) -> Result<(), NikaError> {
927    let root = find_course_root()?;
928    let mut progress = load_progress(&root)?;
929    let level = resolve_level(level_arg)?;
930
931    progress.reset_level(level.number);
932    save_progress(&root, &mut progress)?;
933
934    println!();
935    println!(
936        "  {} Level {:02}: {} reset to start",
937        "<<".yellow().bold(),
938        level.number,
939        level.name.bold()
940    );
941    println!("  {}", "Exercises and hints cleared. Good luck!".dimmed());
942    println!();
943
944    Ok(())
945}
946
947/// nika course run <exercise> — run an exercise workflow
948fn cmd_run(exercise_arg: &str) -> Result<(), NikaError> {
949    let root = find_course_root()?;
950    let (level_num, ex_num) = parse_exercise_id(exercise_arg)?;
951    let path = exercise_path(&root, level_num, ex_num)?;
952
953    if !path.exists() {
954        return Err(NikaError::ValidationError {
955            reason: format!(
956                "Exercise file not found: {}. Generate course files with `nika init --course`.",
957                path.display()
958            ),
959        });
960    }
961
962    let level = levels::by_number(level_num).ok_or_else(|| NikaError::CourseNotFound {
963        path: format!("level {}", level_num),
964    })?;
965    println!();
966    println!(
967        "  {} Running {:02}-{:02} ({} -- exercise {})",
968        ">>".cyan().bold(),
969        level_num,
970        ex_num,
971        level.name,
972        ex_num
973    );
974    println!("  {}", format!("nika run {}", path.display()).dimmed());
975    println!();
976
977    // QW #2: Shell out using current executable for reliable dispatch
978    let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("nika"));
979    let status = std::process::Command::new(&exe)
980        .arg("run")
981        .arg(&path)
982        .status()
983        .map_err(|e| NikaError::ValidationError {
984            reason: format!("Failed to execute nika run: {}", e),
985        })?;
986
987    if !status.success() {
988        return Err(NikaError::ValidationError {
989            reason: format!(
990                "Exercise {:02}-{:02} failed with exit code {}",
991                level_num,
992                ex_num,
993                status.code().unwrap_or(-1)
994            ),
995        });
996    }
997
998    Ok(())
999}
1000
1001/// nika course info [level] — show course or level details
1002fn cmd_info(level_arg: Option<String>) -> Result<(), NikaError> {
1003    match level_arg {
1004        Some(ref arg) => {
1005            let level = resolve_level(arg)?;
1006            let root = find_course_root()?;
1007            let progress = load_progress(&root)?;
1008            let key = level.number.to_string();
1009            let lp = progress.levels.get(&key);
1010
1011            println!();
1012            println!(
1013                "  {} Level {:02}: {}",
1014                if level.boss { "***" } else { ">>>" }.cyan().bold(),
1015                level.number,
1016                level.name.bold()
1017            );
1018            println!("  {}", level.description);
1019            println!();
1020
1021            let status = lp.map(|l| &l.status).unwrap_or(&LevelStatus::Locked);
1022            let status_str = match status {
1023                LevelStatus::Locked => "Locked".dimmed().to_string(),
1024                LevelStatus::Unlocked => "Unlocked".cyan().to_string(),
1025                LevelStatus::InProgress => "In Progress".yellow().to_string(),
1026                LevelStatus::Completed => "Completed".green().to_string(),
1027            };
1028            println!("  Status: {status_str}");
1029
1030            if level.boss {
1031                println!(
1032                    "  {}",
1033                    "BOSS LEVEL -- requires mastery of all prior levels".red()
1034                );
1035            }
1036            println!();
1037
1038            // Exercise list
1039            println!("  Exercises ({}):", level.exercise_count);
1040            for ex in 1..=level.exercise_count {
1041                let ex_key = ex.to_string();
1042                let ex_status = lp
1043                    .and_then(|l| l.exercises.get(&ex_key))
1044                    .unwrap_or(&ExerciseStatus::NotStarted);
1045                let icon = exercise_status_icon(ex_status);
1046                let path = exercise_path(&root, level.number, ex)?;
1047                let exists = if path.exists() { "" } else { " (missing)" };
1048                println!(
1049                    "    {} {:02}-{:02}{}",
1050                    icon,
1051                    level.number,
1052                    ex,
1053                    exists.dimmed()
1054                );
1055            }
1056
1057            if let Some(lp) = lp {
1058                if lp.hints_used > 0 {
1059                    println!();
1060                    println!("  Hints used: {}", lp.hints_used);
1061                }
1062            }
1063            println!();
1064        }
1065        None => {
1066            // Overview of all levels
1067            println!();
1068            println!("{}", "  NIKA COURSE -- 12 Levels to Liberation".bold());
1069            println!();
1070            println!(
1071                "  {} exercises across {} levels",
1072                levels::total_exercises().to_string().cyan(),
1073                LEVELS.len().to_string().cyan()
1074            );
1075            println!();
1076
1077            for level in LEVELS {
1078                let boss = if level.boss { " [BOSS]" } else { "" };
1079                println!(
1080                    "  {:02}. {} ({} exercises){}",
1081                    level.number,
1082                    level.name.bold(),
1083                    level.exercise_count,
1084                    boss.red()
1085                );
1086                println!("      {}", level.description.dimmed());
1087            }
1088            println!();
1089            println!(
1090                "  {}",
1091                "Use `nika course info <level>` for details.".dimmed()
1092            );
1093            println!();
1094        }
1095    }
1096
1097    Ok(())
1098}
1099
1100/// nika course watch — rustlings-style auto-check on file save
1101fn cmd_watch() -> Result<(), NikaError> {
1102    let root = find_course_root()?;
1103    let progress = load_progress(&root)?;
1104
1105    let (level_num, _) = find_current_exercise(&progress)?;
1106    let level = levels::by_number(level_num).ok_or_else(|| NikaError::ValidationError {
1107        reason: format!("Level {} not found", level_num),
1108    })?;
1109
1110    let level_dir = root.join(format!("{:02}-{}", level.number, level.slug));
1111
1112    if !level_dir.is_dir() {
1113        return Err(NikaError::ValidationError {
1114            reason: format!(
1115                "Level directory not found: {}. Run `nika init --course` first.",
1116                level_dir.display()
1117            ),
1118        });
1119    }
1120
1121    println!();
1122    println!(
1123        "  {} Watching Level {:02}: {} for changes...",
1124        ">>>".cyan().bold(),
1125        level.number,
1126        level.name.bold()
1127    );
1128    println!("  {}", level_dir.display().to_string().dimmed());
1129    println!("  {}", "Press Ctrl+C to stop.".dimmed());
1130    println!();
1131
1132    let mut mtimes: HashMap<PathBuf, SystemTime> = HashMap::new();
1133    seed_mtimes(&level_dir, &mut mtimes);
1134
1135    loop {
1136        if let Some(changed_path) = scan_for_changes(&level_dir, &mut mtimes) {
1137            print!("\x1b[2J\x1b[H");
1138            println!();
1139            println!(
1140                "  {} File changed: {}",
1141                ">>>".cyan().bold(),
1142                changed_path
1143                    .file_name()
1144                    .unwrap_or_default()
1145                    .to_string_lossy()
1146                    .cyan()
1147            );
1148            println!();
1149
1150            // Re-run level checks (reuses cmd_check infrastructure + updates progress)
1151            let _ = cmd_check(Some(level.number.to_string()));
1152
1153            println!("  {}", "Watching... Ctrl+C to stop".dimmed());
1154        }
1155
1156        std::thread::sleep(std::time::Duration::from_millis(500));
1157    }
1158}
1159
1160fn seed_mtimes(level_dir: &Path, mtimes: &mut HashMap<PathBuf, SystemTime>) {
1161    if let Ok(entries) = std::fs::read_dir(level_dir) {
1162        for entry in entries.flatten() {
1163            let path = entry.path();
1164            if is_nika_yaml(&path) {
1165                if let Ok(meta) = std::fs::metadata(&path) {
1166                    if let Ok(mtime) = meta.modified() {
1167                        mtimes.insert(path, mtime);
1168                    }
1169                }
1170            }
1171        }
1172    }
1173}
1174
1175fn scan_for_changes(
1176    level_dir: &Path,
1177    mtimes: &mut HashMap<PathBuf, SystemTime>,
1178) -> Option<PathBuf> {
1179    let entries = std::fs::read_dir(level_dir).ok()?;
1180    for entry in entries.flatten() {
1181        let path = entry.path();
1182        if !is_nika_yaml(&path) {
1183            continue;
1184        }
1185        let mtime = match std::fs::metadata(&path).and_then(|m| m.modified()) {
1186            Ok(t) => t,
1187            Err(_) => continue,
1188        };
1189        let changed = mtimes.get(&path).is_none_or(|prev| *prev != mtime);
1190        if changed {
1191            mtimes.insert(path.clone(), mtime);
1192            return Some(path);
1193        }
1194    }
1195    None
1196}
1197
1198fn is_nika_yaml(path: &Path) -> bool {
1199    path.extension().is_some_and(|e| e == "yaml") && path.to_string_lossy().ends_with(".nika.yaml")
1200}
1201
1202// ─── Helpers ────────────────────────────────────────────────────────────────
1203
1204/// Find the current exercise (first non-passed in the active level)
1205fn find_current_exercise(progress: &CourseProgress) -> Result<(u8, u8), NikaError> {
1206    for level in LEVELS {
1207        let key = level.number.to_string();
1208        let lp = match progress.levels.get(&key) {
1209            Some(lp) => lp,
1210            None => continue,
1211        };
1212
1213        match lp.status {
1214            LevelStatus::Locked | LevelStatus::Completed => continue,
1215            LevelStatus::Unlocked | LevelStatus::InProgress => {}
1216        }
1217
1218        for ex in 1..=level.exercise_count {
1219            let ex_key = ex.to_string();
1220            let status = lp
1221                .exercises
1222                .get(&ex_key)
1223                .unwrap_or(&ExerciseStatus::NotStarted);
1224            if *status != ExerciseStatus::Passed && *status != ExerciseStatus::Perfect {
1225                return Ok((level.number, ex));
1226            }
1227        }
1228    }
1229
1230    Err(NikaError::ValidationError {
1231        reason: "All exercises completed! Nothing to hint about.".to_string(),
1232    })
1233}
1234
1235// ─── Tests ──────────────────────────────────────────────────────────────────
1236
1237#[cfg(test)]
1238mod tests {
1239    use super::*;
1240
1241    #[test]
1242    fn test_resolve_level_by_number() {
1243        let level = resolve_level("1").unwrap();
1244        assert_eq!(level.number, 1);
1245        assert_eq!(level.slug, "jailbreak");
1246    }
1247
1248    #[test]
1249    fn test_resolve_level_by_padded_number() {
1250        let level = resolve_level("01").unwrap();
1251        assert_eq!(level.number, 1);
1252
1253        let level = resolve_level("12").unwrap();
1254        assert_eq!(level.number, 12);
1255    }
1256
1257    #[test]
1258    fn test_resolve_level_by_slug() {
1259        let level = resolve_level("jailbreak").unwrap();
1260        assert_eq!(level.number, 1);
1261
1262        let level = resolve_level("hot-wire").unwrap();
1263        assert_eq!(level.number, 2);
1264
1265        let level = resolve_level("supernovae").unwrap();
1266        assert_eq!(level.number, 12);
1267    }
1268
1269    #[test]
1270    fn test_resolve_level_by_name() {
1271        let level = resolve_level("Jailbreak").unwrap();
1272        assert_eq!(level.number, 1);
1273
1274        let level = resolve_level("Hot Wire").unwrap();
1275        assert_eq!(level.number, 2);
1276
1277        let level = resolve_level("SuperNovae").unwrap();
1278        assert_eq!(level.number, 12);
1279    }
1280
1281    #[test]
1282    fn test_resolve_level_case_insensitive() {
1283        let level = resolve_level("JAILBREAK").unwrap();
1284        assert_eq!(level.number, 1);
1285
1286        let level = resolve_level("Hot wire").unwrap();
1287        assert_eq!(level.number, 2);
1288    }
1289
1290    #[test]
1291    fn test_resolve_level_invalid() {
1292        assert!(resolve_level("0").is_err());
1293        assert!(resolve_level("13").is_err());
1294        assert!(resolve_level("nonexistent").is_err());
1295        assert!(resolve_level("").is_err());
1296    }
1297
1298    #[test]
1299    fn test_parse_exercise_id_valid() {
1300        let (l, e) = parse_exercise_id("01-03").unwrap();
1301        assert_eq!(l, 1);
1302        assert_eq!(e, 3);
1303
1304        let (l, e) = parse_exercise_id("12-05").unwrap();
1305        assert_eq!(l, 12);
1306        assert_eq!(e, 5);
1307    }
1308
1309    #[test]
1310    fn test_parse_exercise_id_invalid_format() {
1311        assert!(parse_exercise_id("1").is_err());
1312        assert!(parse_exercise_id("1-2-3").is_err());
1313        assert!(parse_exercise_id("").is_err());
1314        assert!(parse_exercise_id("ab-cd").is_err());
1315    }
1316
1317    #[test]
1318    fn test_parse_exercise_id_out_of_range() {
1319        // Level 1 has 5 exercises
1320        assert!(parse_exercise_id("01-06").is_err());
1321        assert!(parse_exercise_id("01-00").is_err());
1322        // Level 13 doesn't exist
1323        assert!(parse_exercise_id("13-01").is_err());
1324    }
1325
1326    #[test]
1327    fn test_exercise_path_format() {
1328        let root = PathBuf::from("/project");
1329        let path = exercise_path(&root, 1, 3).unwrap();
1330        // Level 1, exercise 3 = "03-http-requests.nika.yaml"
1331        assert_eq!(
1332            path,
1333            PathBuf::from("/project/01-jailbreak/03-http-requests.nika.yaml")
1334        );
1335    }
1336
1337    #[test]
1338    fn test_exercise_path_level_12() {
1339        let root = PathBuf::from("/project");
1340        let path = exercise_path(&root, 12, 5).unwrap();
1341        // Level 12, exercise 5 = "05-full-stack.nika.yaml"
1342        assert_eq!(
1343            path,
1344            PathBuf::from("/project/12-supernovae/05-full-stack.nika.yaml")
1345        );
1346    }
1347
1348    #[test]
1349    fn test_exercise_path_no_course_prefix() {
1350        let root = PathBuf::from("/project");
1351        let path = exercise_path(&root, 1, 1).unwrap();
1352        // Must NOT contain "course/" prefix
1353        assert!(
1354            !path.to_string_lossy().contains("/course/"),
1355            "Path should not have course/ prefix: {}",
1356            path.display()
1357        );
1358        assert_eq!(
1359            path,
1360            PathBuf::from("/project/01-jailbreak/01-hello-world.nika.yaml")
1361        );
1362    }
1363
1364    #[test]
1365    fn test_build_checks_level_1_ex2_exec() {
1366        let yaml = "schema: \"nika/workflow@0.12\"\nworkflow: test\ntasks:\n  - id: hello\n    exec:\n      run: echo hi\n";
1367        let checks = build_checks_for_level(1, 2, yaml);
1368        assert!(checks.iter().all(|c| c.verdict.is_pass()));
1369    }
1370
1371    #[test]
1372    fn test_build_checks_level_1_ex1_infer() {
1373        let yaml = "schema: \"nika/workflow@0.12\"\nworkflow: test\ntasks:\n  - id: hello\n    infer: \"say hi\"\n";
1374        let checks = build_checks_for_level(1, 1, yaml);
1375        assert!(checks.iter().all(|c| c.verdict.is_pass()));
1376    }
1377
1378    #[test]
1379    fn test_build_checks_level_1_ex2_missing_verb() {
1380        let yaml = "schema: \"nika/workflow@0.12\"\nworkflow: test\ntasks:\n  - id: hello\n    fetch:\n      url: \"https://example.com\"\n";
1381        let checks = build_checks_for_level(1, 2, yaml);
1382        // Should fail because exec: is missing for exercise 2
1383        let has_fail = checks
1384            .iter()
1385            .any(|c| matches!(c.verdict, CheckVerdict::Fail(_)));
1386        assert!(has_fail);
1387    }
1388
1389    #[test]
1390    fn test_build_checks_level_2_with_bindings() {
1391        let yaml = "schema: \"nika/workflow@0.12\"\nworkflow: test\ntasks:\n  - id: step1\n    exec: \"date\"\n  - id: step2\n    with:\n      data: $step1\n    exec: \"echo with.data\"\n";
1392        let checks = build_checks_for_level(2, 1, yaml);
1393        assert!(
1394            checks.iter().all(|c| c.verdict.is_pass()),
1395            "Level 2 should check for with: bindings, not fetch:"
1396        );
1397    }
1398
1399    #[test]
1400    fn test_build_checks_level_12_boss() {
1401        let yaml = "schema: \"nika/workflow@0.12\"\nworkflow: boss\ntasks:\n  - id: step1\n    exec:\n      run: echo 1\n  - id: step2\n    depends_on: [step1]\n    with:\n      data: $step1\n    infer:\n      prompt: \"with.data\"\n  - id: step3\n    depends_on: [step2]\n    with:\n      result: $step2\n    exec:\n      run: echo done\n";
1402        let checks = build_checks_for_level(12, 1, yaml);
1403        assert!(
1404            checks.iter().all(|c| c.verdict.is_pass()),
1405            "Boss level checks should all pass for a well-formed workflow"
1406        );
1407    }
1408
1409    #[test]
1410    fn test_find_current_exercise_fresh_course() {
1411        let progress = CourseProgress::new_course();
1412        let (level, ex) = find_current_exercise(&progress).unwrap();
1413        assert_eq!(level, 1);
1414        assert_eq!(ex, 1);
1415    }
1416
1417    #[test]
1418    fn test_find_current_exercise_mid_level() {
1419        let mut progress = CourseProgress::new_course();
1420        progress.mark_exercise_passed(1, 1);
1421        progress.mark_exercise_passed(1, 2);
1422        let (level, ex) = find_current_exercise(&progress).unwrap();
1423        assert_eq!(level, 1);
1424        assert_eq!(ex, 3);
1425    }
1426
1427    #[test]
1428    fn test_find_current_exercise_next_level() {
1429        let mut progress = CourseProgress::new_course();
1430        // Complete all of level 1
1431        for ex in 1..=5 {
1432            progress.mark_exercise_passed(1, ex);
1433        }
1434        let (level, ex) = find_current_exercise(&progress).unwrap();
1435        assert_eq!(level, 2);
1436        assert_eq!(ex, 1);
1437    }
1438
1439    #[test]
1440    fn test_constellation_star_non_empty() {
1441        assert!(!constellation_star(&LevelStatus::Locked, false).is_empty());
1442        assert!(!constellation_star(&LevelStatus::Unlocked, false).is_empty());
1443        assert!(!constellation_star(&LevelStatus::InProgress, false).is_empty());
1444        assert!(!constellation_star(&LevelStatus::Completed, false).is_empty());
1445        assert!(!constellation_star(&LevelStatus::Completed, true).is_empty());
1446    }
1447
1448    #[test]
1449    fn test_exercise_status_icons_are_non_empty() {
1450        assert!(!exercise_status_icon(&ExerciseStatus::NotStarted).is_empty());
1451        assert!(!exercise_status_icon(&ExerciseStatus::Attempted).is_empty());
1452        assert!(!exercise_status_icon(&ExerciseStatus::Passed).is_empty());
1453        assert!(!exercise_status_icon(&ExerciseStatus::Perfect).is_empty());
1454    }
1455}