nika-init 0.64.0

Nika project scaffolding — course generator, workflow templates, showcase
Documentation
//! Course directory generator — creates the full course structure on disk.
//!
//! Called by `nika init --course [DEST]`. Generates:
//! - nika.toml + .nika/ with course-progress.toml
//! - 12 level directories with exercises, solutions, and MISSION.md
//!
//! All content is embedded in the binary via exercises.rs — no network needed.

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;

/// Env-var-to-provider mapping for auto-detection.
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"),
];

/// Auto-detect an LLM provider from environment variables.
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")
}

/// Configuration for course generation
#[derive(Debug, Clone)]
pub struct CourseConfig {
    /// Destination directory (default: ./nika-course)
    pub dest: PathBuf,
    /// Provider to substitute in templates (auto-detected)
    pub provider: String,
    /// Model to substitute in templates
    pub model: String,
    /// Theme name (for progress file)
    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(),
        }
    }
}

/// Result of course generation
#[derive(Debug)]
pub struct CourseResult {
    /// Root directory of the generated course
    pub root: PathBuf,
    /// Number of levels generated
    pub levels: usize,
    /// Number of exercise files generated
    pub exercises: usize,
    /// Number of solution files generated
    pub solutions: usize,
    /// Provider used in templates
    pub provider: String,
}

/// Generate the full course directory structure.
///
/// Creates:
/// ```text
/// {dest}/
/// ├── nika.toml
/// ├── .nika/
/// │   └── course-progress.toml
/// ├── 01-jailbreak/
/// │   ├── MISSION.md
/// │   ├── 01-hello-world.nika.yaml
/// │   ├── ...
/// │   └── .solutions/
/// │       ├── 01-hello-world.nika.yaml
/// │       └── ...
/// ├── 02-hot-wire/
/// │   └── ...
/// └── README.md
/// ```
pub fn generate_course(config: &CourseConfig) -> Result<CourseResult> {
    let dest = &config.dest;

    // Don't overwrite existing course
    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()
            ),
        });
    }

    // Create root directory
    fs::create_dir_all(dest).map_err(NikaInitError::IoError)?;

    // Create nika.toml (project config — versioned)
    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)?;

    // Create .nika/ directory for runtime state
    let nika_dir = dest.join(".nika");
    fs::create_dir_all(&nika_dir).map_err(NikaInitError::IoError)?;

    // Write initial course progress
    let mut progress = CourseProgress::new_course();
    let progress_path = nika_dir.join("course-progress.toml");
    progress.save(&progress_path)?;

    // Collect all exercises (L1-6 + L7-12)
    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;

    // Generate each level directory
    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)?;

        // Create .solutions/ directory
        let solutions_dir = level_dir.join(".solutions");
        fs::create_dir_all(&solutions_dir).map_err(NikaInitError::IoError)?;

        // Write MISSION.md
        let mission = missions::get_mission(level.slug);
        fs::write(level_dir.join("MISSION.md"), mission).map_err(NikaInitError::IoError)?;

        // Write exercises for this level
        let level_exercises: Vec<&ExerciseContent> = all_exercises
            .iter()
            .filter(|e| e.level_slug == level.slug)
            .copied()
            .collect();

        for exercise in &level_exercises {
            // Substitute placeholders in template
            let template = substitute_placeholders(exercise.template, config);
            fs::write(level_dir.join(exercise.filename), template)
                .map_err(NikaInitError::IoError)?;
            exercise_count += 1;

            // Substitute placeholders in solution
            let solution = substitute_placeholders(exercise.solution, config);
            fs::write(solutions_dir.join(exercise.filename), solution)
                .map_err(NikaInitError::IoError)?;
            solution_count += 1;
        }
    }

    // Write course README.md
    let readme = generate_readme(config);
    fs::write(dest.join("README.md"), readme).map_err(NikaInitError::IoError)?;

    // Write .gitignore for .solutions directories
    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(),
    })
}

/// Substitute {{PROVIDER}} and {{MODEL}} placeholders
fn substitute_placeholders(content: &str, config: &CourseConfig) -> String {
    content
        .replace("{{PROVIDER}}", &config.provider)
        .replace("{{MODEL}}", &config.model)
}

/// Generate course README.md
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();

        // Check structure
        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 once
        generate_course(&config).unwrap();
        // Try again — should fail
        let err = generate_course(&config).unwrap_err();
        assert!(err.to_string().contains("already exists"));
    }
}