use std::env;
use std::fs;
use std::path::PathBuf;
use crate::error::{NikaInitError, Result};
use super::exercises::ExerciseContent;
use super::levels::LEVELS;
use super::missions;
use super::progress::CourseProgress;
const PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[
("ANTHROPIC_API_KEY", "claude", "claude-sonnet-4-6"),
("OPENAI_API_KEY", "openai", "gpt-4o"),
("GROQ_API_KEY", "groq", "llama-3.3-70b-versatile"),
("MISTRAL_API_KEY", "mistral", "mistral-large-latest"),
("DEEPSEEK_API_KEY", "deepseek", "deepseek-chat"),
("GEMINI_API_KEY", "gemini", "gemini-2.0-flash"),
("XAI_API_KEY", "xai", "grok-3-fast"),
];
pub fn detect_provider() -> (&'static str, &'static str) {
for &(var, slug, model) in PROVIDER_ENV_VARS {
if env::var(var).map(|v| !v.is_empty()).unwrap_or(false) {
return (slug, model);
}
}
("claude", "claude-sonnet-4-6")
}
#[derive(Debug, Clone)]
pub struct CourseConfig {
pub dest: PathBuf,
pub provider: String,
pub model: String,
pub theme: String,
}
impl Default for CourseConfig {
fn default() -> Self {
let (provider, model) = detect_provider();
Self {
dest: PathBuf::from("nika-course"),
provider: provider.into(),
model: model.into(),
theme: "liberation".into(),
}
}
}
#[derive(Debug)]
pub struct CourseResult {
pub root: PathBuf,
pub levels: usize,
pub exercises: usize,
pub solutions: usize,
pub provider: String,
}
pub fn generate_course(config: &CourseConfig) -> Result<CourseResult> {
let dest = &config.dest;
if dest.join(".nika").join("course-progress.toml").exists() {
return Err(NikaInitError::ValidationError {
reason: format!(
"Course already exists at {}. Use `nika course reset` to start over.",
dest.display()
),
});
}
fs::create_dir_all(dest).map_err(NikaInitError::IoError)?;
let config_content = format!(
r#"# Nika Course Configuration
# Generated by `nika init --course`
[project]
name = "nika-course"
[tools]
permission = "plan"
[provider]
default = "{}"
"#,
config.provider
);
fs::write(dest.join("nika.toml"), config_content).map_err(NikaInitError::IoError)?;
let nika_dir = dest.join(".nika");
fs::create_dir_all(&nika_dir).map_err(NikaInitError::IoError)?;
let mut progress = CourseProgress::new_course();
let progress_path = nika_dir.join("course-progress.toml");
progress.save(&progress_path)?;
let all_exercises: Vec<&super::exercises::ExerciseContent> = {
let mut v: Vec<&super::exercises::ExerciseContent> = Vec::new();
for e in super::exercises::EXERCISES {
v.push(e);
}
for e in super::exercises_advanced::EXERCISES_ADVANCED {
v.push(e);
}
v
};
let mut exercise_count = 0;
let mut solution_count = 0;
for level in LEVELS.iter() {
let level_dir = dest.join(format!("{:02}-{}", level.number, level.slug));
fs::create_dir_all(&level_dir).map_err(NikaInitError::IoError)?;
let solutions_dir = level_dir.join(".solutions");
fs::create_dir_all(&solutions_dir).map_err(NikaInitError::IoError)?;
let mission = missions::get_mission(level.slug);
fs::write(level_dir.join("MISSION.md"), mission).map_err(NikaInitError::IoError)?;
let level_exercises: Vec<&ExerciseContent> = all_exercises
.iter()
.filter(|e| e.level_slug == level.slug)
.copied()
.collect();
for exercise in &level_exercises {
let template = substitute_placeholders(exercise.template, config);
fs::write(level_dir.join(exercise.filename), template)
.map_err(NikaInitError::IoError)?;
exercise_count += 1;
let solution = substitute_placeholders(exercise.solution, config);
fs::write(solutions_dir.join(exercise.filename), solution)
.map_err(NikaInitError::IoError)?;
solution_count += 1;
}
}
let readme = generate_readme(config);
fs::write(dest.join("README.md"), readme).map_err(NikaInitError::IoError)?;
let gitignore = "# Hide solutions from git\n.solutions/\n\n# Nika runtime\n.nika/traces/\n.nika/media/\n.nika/cache/\noutput/\n";
fs::write(dest.join(".gitignore"), gitignore).map_err(NikaInitError::IoError)?;
Ok(CourseResult {
root: dest.clone(),
levels: LEVELS.len(),
exercises: exercise_count,
solutions: solution_count,
provider: config.provider.clone(),
})
}
fn substitute_placeholders(content: &str, config: &CourseConfig) -> String {
content
.replace("{{PROVIDER}}", &config.provider)
.replace("{{MODEL}}", &config.model)
}
fn generate_readme(config: &CourseConfig) -> String {
let mut levels_table = String::new();
for level in LEVELS.iter() {
let boss = if level.boss { " \u{1f3f4}" } else { "" };
levels_table.push_str(&format!(
"| {:02} | {} | {} | {} ex |{boss}\n",
level.number, level.name, level.slug, level.exercise_count,
));
}
format!(
r#"# Nika Course — Your Liberation Journey
> This isn't a course. It's a jailbreak manual.
## Quick Start
```bash
nika course status # See your constellation map
nika course next # Open next exercise
nika course check # Validate your work
nika course hint # Get progressive hints
```
## The 12 Levels
| # | Level | Slug | Size |
|---|-------|------|------|
{levels_table}
## Provider: {provider}
Your exercises are configured for `{provider}`. To change:
```bash
# Edit exercises manually, or regenerate:
nika init --course --provider openai
```
## Rules
1. Open the exercise file
2. Fill in the TODO markers
3. Run `nika course check` to validate
4. Use `nika course hint` if stuck (no penalty!)
5. Move to next exercise with `nika course next`
## Scoring
- * Correctness: all checks pass
- * Elegance: idiomatic usage
- * Bonus: first-try, no hints
Hints are FREE. Use them. Learning > ego.
---
Generated by Nika v{version} | Theme: {theme}
"#,
provider = config.provider,
version = env!("CARGO_PKG_VERSION"),
theme = config.theme,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_substitute_placeholders() {
let config = CourseConfig::default();
let result = substitute_placeholders("provider: {{PROVIDER}}\nmodel: {{MODEL}}", &config);
assert!(result.contains("claude"));
assert!(result.contains("claude-sonnet-4-6"));
assert!(!result.contains("{{"));
}
#[test]
fn test_get_mission_jailbreak() {
let mission = missions::get_mission("jailbreak");
assert!(mission.contains("Jailbreak"));
assert!(mission.contains("What You'll Learn"));
assert!(mission.contains("Exercises"));
}
#[test]
fn test_get_mission_boss() {
let mission = missions::get_mission("supernovae");
assert!(mission.contains("BOSS BATTLE"));
assert!(mission.contains("SuperNovae"));
}
#[test]
fn test_generate_readme() {
let config = CourseConfig::default();
let readme = generate_readme(&config);
assert!(readme.contains("Liberation Journey"));
assert!(readme.contains("Jailbreak"));
assert!(readme.contains("SuperNovae"));
assert!(readme.contains(&config.provider));
}
#[test]
fn test_default_config() {
let config = CourseConfig::default();
let (expected_provider, _) = detect_provider();
assert_eq!(config.provider, expected_provider);
assert_eq!(config.theme, "liberation");
assert_eq!(config.dest, PathBuf::from("nika-course"));
}
#[test]
fn test_generate_course_in_tempdir() {
let dir = tempfile::tempdir().unwrap();
let config = CourseConfig {
dest: dir.path().to_path_buf(),
..CourseConfig::default()
};
let result = generate_course(&config).unwrap();
assert_eq!(result.levels, 12);
assert!(result.exercises > 0);
assert!(dir.path().join("nika.toml").exists());
assert!(dir
.path()
.join(".nika")
.join("course-progress.toml")
.exists());
assert!(dir.path().join("01-jailbreak").exists());
assert!(dir.path().join("01-jailbreak").join("MISSION.md").exists());
assert!(dir.path().join("01-jailbreak").join(".solutions").exists());
assert!(dir.path().join("12-supernovae").exists());
assert!(dir.path().join("README.md").exists());
assert!(dir.path().join(".gitignore").exists());
}
#[test]
fn test_generate_course_no_overwrite() {
let dir = tempfile::tempdir().unwrap();
let config = CourseConfig {
dest: dir.path().to_path_buf(),
..CourseConfig::default()
};
generate_course(&config).unwrap();
let err = generate_course(&config).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
}