practicode 0.2.5

Offline-first programming language lessons and coding practice in a Rust terminal UI.
Documentation
use super::*;

pub fn starter_problem() -> Problem {
    Problem {
        id: "001-hello-world".to_string(),
        slug: "hello-world".to_string(),
        difficulty: "easy".to_string(),
        topics: vec!["io".to_string()],
        title: localized_map(&[
            ("en", "Hello World"),
            ("ko", "Hello World"),
            ("ja", "Hello World"),
            ("zh", "Hello World"),
            ("es", "Hello World"),
        ]),
        statement: localized_map(&[
            ("en", "Print exactly `Hello, World!` to stdout."),
            ("ko", "표준 출력으로 정확히 `Hello, World!`를 출력하세요."),
            ("ja", "標準出力に正確に `Hello, World!` を出力してください。"),
            ("zh", "向标准输出准确打印 `Hello, World!`。"),
            ("es", "Imprime exactamente `Hello, World!` en stdout."),
        ]),
        input: localized_map(&[
            ("en", "No input."),
            ("ko", "입력은 없습니다."),
            ("ja", "入力はありません。"),
            ("zh", "没有输入。"),
            ("es", "No hay entrada."),
        ]),
        output: localized_map(&[
            ("en", "One line: `Hello, World!`"),
            ("ko", "`Hello, World!` 한 줄"),
            ("ja", "1行: `Hello, World!`"),
            ("zh", "一行: `Hello, World!`"),
            ("es", "Una linea: `Hello, World!`"),
        ]),
        examples: vec![IoCase {
            input: String::new(),
            output: "Hello, World!\n".to_string(),
        }],
        cases: vec![IoCase {
            input: String::new(),
            output: "Hello, World!\n".to_string(),
        }],
        answers: HashMap::from([
            ("python".to_string(), "print('Hello, World!')\n".to_string()),
            (
                "ts".to_string(),
                "console.log('Hello, World!');\n".to_string(),
            ),
            (
                "java".to_string(),
                "class Solution {\n    public static void main(String[] args) {\n        System.out.println(\"Hello, World!\");\n    }\n}\n".to_string(),
            ),
            (
                "rust".to_string(),
                "fn main() {\n    println!(\"Hello, World!\");\n}\n".to_string(),
            ),
        ]),
    }
}

pub fn map2(k1: &str, v1: &str, k2: &str, v2: &str) -> HashMap<String, String> {
    HashMap::from([
        (k1.to_string(), v1.to_string()),
        (k2.to_string(), v2.to_string()),
    ])
}

pub fn localized_map(entries: &[(&str, &str)]) -> HashMap<String, String> {
    entries
        .iter()
        .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
        .collect()
}

pub fn load_bank(root: &Path) -> Result<Vec<Problem>> {
    let path = root.join(BANK_PATH);
    if regular_file_exists(&path)? {
        let text = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
        let bank: Vec<Problem> =
            serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
        validate_bank(&bank, &path)?;
        Ok(bank)
    } else {
        Ok(vec![starter_problem()])
    }
}

pub fn save_bank(root: &Path, bank: &[Problem]) -> Result<()> {
    let path = root.join(BANK_PATH);
    validate_bank(bank, &path)?;
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)?;
    }
    save_user_text(&path, &(serde_json::to_string_pretty(bank)? + "\n"))
}

fn validate_bank(bank: &[Problem], path: &Path) -> Result<()> {
    if bank.is_empty() {
        bail!("{} must contain at least one problem", path.display());
    }
    let mut ids = Vec::new();
    let mut slugs = Vec::new();
    for problem in bank {
        if !is_safe_name(&problem.id) {
            bail!("{} has invalid problem id {:?}", path.display(), problem.id);
        }
        if !is_safe_name(&problem.slug) {
            bail!(
                "{} has invalid slug {:?} for {}",
                path.display(),
                problem.slug,
                problem.id
            );
        }
        if ids.contains(&problem.id.as_str()) {
            bail!("{} has duplicate problem id {}", path.display(), problem.id);
        }
        if slugs.contains(&problem.slug.as_str()) {
            bail!("{} has duplicate slug {}", path.display(), problem.slug);
        }
        ids.push(problem.id.as_str());
        slugs.push(problem.slug.as_str());
        if problem.cases.is_empty() {
            bail!(
                "{} problem {} has no judge cases",
                path.display(),
                problem.id
            );
        }
        if problem.answers.is_empty() {
            bail!(
                "{} problem {} must contain at least one answer",
                path.display(),
                problem.id
            );
        }
        for language in problem.answers.keys() {
            if !LANGUAGES.contains(&language.as_str()) {
                bail!(
                    "{} problem {} has unsupported answer language {language}",
                    path.display(),
                    problem.id,
                );
            }
        }
    }
    Ok(())
}

fn is_safe_name(value: &str) -> bool {
    !value.is_empty()
        && value
            .chars()
            .all(|char| char.is_ascii_alphanumeric() || matches!(char, '-' | '_'))
}