curl-quests 0.1.2

An interactive terminal game for learning curl and HTTP APIs through hands-on quests
use include_dir::{include_dir, Dir};
use rusqlite::Connection;
use rusqlite::types::Value;
use serde::Deserialize;
use std::path::{Path, PathBuf};

static EMBEDDED_QUESTS: Dir = include_dir!("$CARGO_MANIFEST_DIR/quests");

// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------

pub enum VerifyResult {
    Pass,
    Fail(String),
}

pub struct DbCheck {
    pub sql: String,
    pub expected: String,
    pub error: String,
}

pub struct InputCheck {
    pub contains: Vec<String>,
    pub error: String,
}

pub struct QuestSetup {
    pub seed: Vec<String>,
}

#[allow(dead_code)]
pub struct QuestServer {
    pub port: u16,
}

pub struct QuestVerify {
    pub checks: Vec<DbCheck>,
    pub input: Option<InputCheck>,
}

pub struct Quest {
    pub id: usize,
    pub title: String,
    pub instructions: String,
    pub solutions: Vec<String>,
    pub submit_prompt: Option<String>,
    pub setup: Option<QuestSetup>,
    #[allow(dead_code)]
    pub server: Option<QuestServer>,
    pub verify: QuestVerify,
    pub folder_path: PathBuf,
}

impl Quest {
    /// Verify quest DB state by running check queries against the quest database.
    pub fn verify_db(&self, quest_db_path: &Path) -> VerifyResult {
        if self.verify.checks.is_empty() {
            return VerifyResult::Pass;
        }
        let conn = match Connection::open(quest_db_path) {
            Ok(c) => c,
            Err(e) => return VerifyResult::Fail(format!("Could not open quest DB: {e}")),
        };
        for check in &self.verify.checks {
            let result: Result<String, _> = conn.query_row(&check.sql, [], |row| {
                let val: Value = row.get(0)?;
                Ok(match val {
                    Value::Integer(i) => i.to_string(),
                    Value::Real(f) => f.to_string(),
                    Value::Text(s) => s,
                    Value::Blob(b) => String::from_utf8_lossy(&b).to_string(),
                    Value::Null => "NULL".to_string(),
                })
            });
            match result {
                Ok(val) if val == check.expected => continue,
                Ok(_) | Err(_) => return VerifyResult::Fail(check.error.clone()),
            }
        }
        VerifyResult::Pass
    }

    /// Verify user's submitted answer text.
    pub fn verify_input(&self, input: &str) -> VerifyResult {
        if let Some(ref check) = self.verify.input {
            let lower = input.to_lowercase();
            for pattern in &check.contains {
                if !lower.contains(&pattern.to_lowercase()) {
                    return VerifyResult::Fail(check.error.clone());
                }
            }
        }
        VerifyResult::Pass
    }
}

// ---------------------------------------------------------------------------
// TOML schema (private only used during loading)
// ---------------------------------------------------------------------------

#[derive(Deserialize)]
struct QuestToml {
    id: usize,
    title: String,
    instructions: String,
    solutions: Vec<String>,
    submit_prompt: Option<String>,
    setup: Option<SetupToml>,
    server: Option<ServerToml>,
    verify: VerifyToml,
}

#[derive(Deserialize)]
struct SetupToml {
    seed: Vec<String>,
}

#[derive(Deserialize)]
struct ServerToml {
    port: u16,
}

#[derive(Deserialize)]
struct VerifyToml {
    checks: Option<Vec<DbCheckToml>>,
    input: Option<InputCheckToml>,
}

#[derive(Deserialize)]
struct DbCheckToml {
    sql: String,
    expected: String,
    error: String,
}

#[derive(Deserialize)]
struct InputCheckToml {
    contains: Vec<String>,
    error: String,
}

// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------

/// Load all quests.
/// Priority:
/// 1. Local `./quests` directory (for development)
/// 2. Embedded quests extracted to a temporary directory (for cargo install)
pub fn load_quests(quests_dir: &Path) -> Vec<Quest> {
    let mut actual_dir = quests_dir.to_path_buf();

    // If the requested directory doesn't exist, we use the embedded ones.
    if !actual_dir.exists() {
        let temp_dir = std::env::temp_dir().join("curl-quests-assets");
        let _ = std::fs::create_dir_all(&temp_dir);
        
        // Extract embedded quests to temp dir if not already there or to ensure they are up to date.
        // For simplicity, we just extract them every time or check a version file.
        // Here we'll just extract them.
        for entry in EMBEDDED_QUESTS.entries() {
            extract_dir_entry(entry, &temp_dir);
        }
        actual_dir = temp_dir;
    }

    let mut quests = Vec::new();
    let entries = match std::fs::read_dir(&actual_dir) {
        Ok(e) => e,
        Err(_) => return quests,
    };

    let mut dirs: Vec<_> = entries
        .filter_map(|e| e.ok())
        .filter(|e| e.path().is_dir())
        .collect();
    dirs.sort_by_key(|e| e.file_name());

    for entry in dirs {
        let folder = entry.path();
        let config_path = folder.join("quest.toml");
        if !config_path.exists() {
            continue;
        }
        
        let content = match std::fs::read_to_string(&config_path) {
            Ok(s) => s,
            Err(_) => continue,
        };
        let parsed: QuestToml = match toml::from_str(&content) {
            Ok(p) => p,
            Err(e) => {
                let _ = std::fs::write(folder.join("error.log"), format!("TOML Parse Error: {}", e));
                continue;
            }
        };

        let setup = parsed.setup.map(|s| QuestSetup { seed: s.seed });
        let server = parsed.server.map(|s| QuestServer { port: s.port });
        let verify = QuestVerify {
            checks: parsed
                .verify
                .checks
                .unwrap_or_default()
                .into_iter()
                .map(|c| DbCheck {
                    sql: c.sql,
                    expected: c.expected,
                    error: c.error,
                })
                .collect(),
            input: parsed.verify.input.map(|i| InputCheck {
                contains: i.contains,
                error: i.error,
            }),
        };

        quests.push(Quest {
            id: parsed.id,
            title: parsed.title,
            instructions: parsed.instructions,
            solutions: parsed.solutions,
            submit_prompt: parsed.submit_prompt,
            setup,
            server,
            verify,
            folder_path: folder,
        });
    }

    quests.sort_by_key(|q| q.id);
    quests
}

fn extract_dir_entry(entry: &include_dir::DirEntry, base_path: &Path) {
    match entry {
        include_dir::DirEntry::Dir(d) => {
            let path = base_path.join(d.path());
            let _ = std::fs::create_dir_all(&path);
            for e in d.entries() {
                extract_dir_entry(e, base_path);
            }
        }
        include_dir::DirEntry::File(f) => {
            let path = base_path.join(f.path());
            if let Some(parent) = path.parent() {
                let _ = std::fs::create_dir_all(parent);
            }
            let _ = std::fs::write(&path, f.contents());
            
            // Make sure shell scripts are executable on Unix systems.
            #[cfg(unix)]
            {
                use std::os::unix::fs::PermissionsExt;
                if path.extension().map_or(false, |ext| ext == "sh") {
                    let mut perms = std::fs::metadata(&path).unwrap().permissions();
                    perms.set_mode(0o755);
                    let _ = std::fs::set_permissions(&path, perms);
                }
            }
        }
    }
}