1use 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#[derive(Subcommand)]
26pub enum CourseAction {
27 Status,
29 Next,
31 Check {
33 level: Option<String>,
35 },
36 Hint {
38 exercise: Option<String>,
40 },
41 Reset {
43 level: String,
45 },
46 Run {
48 exercise: String,
50 },
51 Info {
53 level: Option<String>,
55 },
56 Watch,
58}
59
60pub 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
74const PROGRESS_FILE: &str = ".nika/course-progress.toml";
78
79fn find_course_root() -> Result<PathBuf, NikaError> {
82 let cwd = std::env::current_dir()?;
83 let mut dir = cwd.as_path();
84
85 loop {
86 if dir.join(PROGRESS_FILE).exists() {
88 return Ok(dir.to_path_buf());
89 }
90 if dir.join("01-jailbreak").is_dir() {
92 return Ok(dir.to_path_buf());
93 }
94 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
109fn 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
119fn save_progress(root: &Path, progress: &mut CourseProgress) -> Result<(), NikaError> {
121 let path = root.join(PROGRESS_FILE);
122 Ok(progress.save(&path)?)
123}
124
125pub fn resolve_level(input: &str) -> Result<&'static Level, NikaError> {
131 if let Ok(n) = input.parse::<u8>() {
133 if let Some(level) = levels::by_number(n) {
134 return Ok(level);
135 }
136 }
137
138 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 let lower = input.to_lowercase();
150 if let Some(level) = levels::by_slug(&lower) {
151 return Ok(level);
152 }
153
154 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
167fn 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 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 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
203fn 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 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 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 Ok(level_dir.join(format!("{:02}-exercise-{}.nika.yaml", exercise, exercise)))
240}
241
242fn 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
253fn 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
416fn 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
433fn cmd_next() -> Result<(), NikaError> {
435 let root = find_course_root()?;
436 let progress = load_progress(&root)?;
437
438 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 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 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
513fn 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 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 let mut checks = build_checks_for_level(level.number, ex, &yaml);
576
577 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 if ex_report.passed() {
596 println!(" {} {}", "PASS".green().bold(), ex_id);
597 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 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_progress(&root, &mut progress)?;
627
628 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 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
733fn 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 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 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 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 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 }
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 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
816fn 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 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 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 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 match next_level {
893 Some(level) => {
894 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 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
925fn 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
947fn 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 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
1001fn 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 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 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
1100fn 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 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
1202fn 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#[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 assert!(parse_exercise_id("01-06").is_err());
1321 assert!(parse_exercise_id("01-00").is_err());
1322 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 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 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 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 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 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}