use std::path::PathBuf; use std::time::Instant; use clap::{Parser, ValueEnum}; use cruciverb_core::clue::seed::load_clue_database; use cruciverb_core::config::{DifficultyPreset, GenerationConfig, SymmetryType}; use cruciverb_core::dict::Dictionary; use cruciverb_core::puzzle::generate_full_puzzle; use cruciverb_core::validate::validate_puzzle; #[derive(Parser)] #[command(name = "cruciverb", about = "Crossword puzzle generator")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(clap::Subcommand)] enum Commands { Generate(GenerateArgs), ImportClues(ImportCluesArgs), } #[derive(Parser)] struct ImportCluesArgs { #[arg(help = "Path to CSV file (columns: Date,Word,Clue)")] csv: PathBuf, #[arg(short, long, default_value = "../data/clues.db")] output: PathBuf, } #[derive(Parser)] struct GenerateArgs { #[arg(short, long, default_value = "15")] size: usize, #[arg(short, long, value_enum)] difficulty: Option, #[arg(long, value_enum, default_value = "rotational180")] symmetry: CliSymmetry, #[arg(short, long)] output: Option, #[arg(long)] seed: Option, #[arg(long, default_value = "0.22")] density: f64, #[arg(long, default_value = "../data/wordlists/enable.txt")] wordlist: PathBuf, #[arg(long)] blocklist: Option, #[arg(long, default_value = "../data")] data_dir: PathBuf, } #[derive(Clone, ValueEnum)] enum CliDifficulty { Glyph, Rune, Scroll, Codex, Grimoire, } #[derive(Clone, ValueEnum)] enum CliSymmetry { Rotational180, Rotational90, Diagonal, Mirror, None, } impl From for SymmetryType { fn from(s: CliSymmetry) -> Self { match s { CliSymmetry::Rotational180 => SymmetryType::Rotational180, CliSymmetry::Rotational90 => SymmetryType::Rotational90, CliSymmetry::Diagonal => SymmetryType::Diagonal, CliSymmetry::Mirror => SymmetryType::Mirror, CliSymmetry::None => SymmetryType::None, } } } fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let cli = Cli::parse(); match cli.command { Commands::Generate(args) => generate(args), Commands::ImportClues(args) => import_clues(args), } } fn generate(args: GenerateArgs) { let start = Instant::now(); let config = match &args.difficulty { Some(d) => { let preset = match d { CliDifficulty::Glyph => DifficultyPreset::Glyph, CliDifficulty::Rune => DifficultyPreset::Rune, CliDifficulty::Scroll => DifficultyPreset::Scroll, CliDifficulty::Codex => DifficultyPreset::Codex, CliDifficulty::Grimoire => DifficultyPreset::Grimoire, }; let mut c = GenerationConfig::from_preset(preset); c.seed = args.seed; c.symmetry = args.symmetry.into(); c } None => GenerationConfig { width: args.size, height: args.size, symmetry: args.symmetry.into(), black_cell_density: args.density, seed: args.seed, rand_top_base: GenerationConfig::rand_top_for_size(args.size), ..Default::default() }, }; let difficulty: u8 = match &args.difficulty { Some(CliDifficulty::Glyph) => 0, Some(CliDifficulty::Rune) => 1, Some(CliDifficulty::Scroll) => 2, Some(CliDifficulty::Codex) => 3, Some(CliDifficulty::Grimoire) => 3, None => 1, }; eprintln!("loading dictionary..."); let mut dict = Dictionary::load( &args.wordlist, args.blocklist.as_deref(), config.min_word_length, ) .unwrap_or_else(|e| { eprintln!("failed to load wordlist: {}", e); std::process::exit(1); }); eprintln!("loaded {} words", dict.word_count()); let clue_db = load_clue_database(&args.data_dir).unwrap_or_else(|e| { eprintln!("warning: failed to load clue database: {}", e); cruciverb_core::clue::ClueDatabase::empty() }); eprintln!("loaded {} clue entries", clue_db.word_count()); let clue_words = clue_db.all_words(); eprintln!("boosting {} words with clue coverage", clue_words.len()); dict.boost_from_clue_words(&clue_words); eprintln!("generating {}x{} puzzle...", config.width, config.height); let puzzle = generate_full_puzzle(&config, &dict, &clue_db, difficulty).unwrap_or_else(|e| { eprintln!("generation failed: {}", e); std::process::exit(1); }); eprintln!("solved in {:.2}s", start.elapsed().as_secs_f64()); let validation = validate_puzzle( &puzzle.grid, &puzzle.words, &dict, config.min_word_length, config.obscurity_budget, ); if !validation.is_valid() { eprintln!("validation warnings:"); for err in &validation.errors { eprintln!(" - {}", err); } } let json = puzzle.to_json().expect("failed to serialize puzzle"); match &args.output { Some(path) => { std::fs::write(path, &json).unwrap_or_else(|e| { eprintln!("failed to write output: {}", e); std::process::exit(1); }); eprintln!("puzzle written to {}", path.display()); } None => println!("{}", json), } eprintln!( "total time: {:.2}s | {} words placed | {}x{} grid", start.elapsed().as_secs_f64(), puzzle.words.len(), config.width, config.height, ); } fn import_clues(args: ImportCluesArgs) { use rusqlite::Connection; use std::collections::HashMap; use std::io::{BufRead, BufReader}; eprintln!("importing clues from {}...", args.csv.display()); let db = Connection::open(&args.output).unwrap_or_else(|e| { eprintln!("failed to open database: {}", e); std::process::exit(1); }); db.execute_batch( " DROP TABLE IF EXISTS clues; DROP TABLE IF EXISTS word_difficulty; CREATE TABLE clues ( id INTEGER PRIMARY KEY, word TEXT NOT NULL, clue TEXT NOT NULL, difficulty INTEGER NOT NULL DEFAULT 1 ); CREATE TABLE word_difficulty ( word TEXT PRIMARY KEY, score REAL NOT NULL ); CREATE INDEX idx_clues_word ON clues(word); CREATE INDEX idx_clues_diff ON clues(word, difficulty); ", ) .unwrap(); let file = std::fs::File::open(&args.csv).unwrap_or_else(|e| { eprintln!("failed to open CSV: {}", e); std::process::exit(1); }); let reader = BufReader::new(file); // day-of-week difficulty: Mon=1 (easy) through Sat=6 (hard), Sun=3 (medium-large) // map to 3 tiers: easy (1-2), medium (3-4), hard (5-6) fn day_of_week(date_str: &str) -> Option { let parts: Vec<&str> = date_str.split('/').collect(); if parts.len() != 3 { return None; } let m: i32 = parts[0].parse().ok()?; let d: i32 = parts[1].parse().ok()?; let y: i32 = parts[2].parse().ok()?; let (y, m) = if m <= 2 { (y - 1, m + 12) } else { (y, m) }; let dow = ((d + 13 * (m + 1) / 5 + y + y / 4 - y / 100 + y / 400) % 7 + 7) % 7; let weight = match dow { 2 => 1, // Monday 3 => 2, // Tuesday 4 => 3, // Wednesday 5 => 4, // Thursday 6 => 5, // Friday 0 => 6, // Saturday 1 => 3, // Sunday (medium) _ => 3, }; Some(weight) } fn weight_to_tier(w: u32) -> u8 { if w <= 2 { 1 } else if w <= 4 { 2 } else { 3 } } db.execute_batch("BEGIN").unwrap(); let mut count = 0u64; let mut word_day_scores: HashMap> = HashMap::new(); let mut stmt = db .prepare("INSERT INTO clues (word, clue, difficulty) VALUES (?1, ?2, ?3)") .unwrap(); for (i, line) in reader.lines().enumerate() { let line = match line { Ok(l) => l, Err(_) => continue, }; if i == 0 && line.starts_with("Date") { continue; } let fields = parse_csv_line(&line); let Some((date, word, clue)) = fields else { continue; }; if word.is_empty() || !word.bytes().all(|b| b.is_ascii_alphabetic()) { continue; } let upper = word.to_uppercase(); let day_score = day_of_week(&date).unwrap_or(3); let tier = weight_to_tier(day_score); stmt.execute(rusqlite::params![upper, clue, tier]).unwrap(); word_day_scores.entry(upper).or_default().push(day_score); count += 1; if count % 100_000 == 0 { eprintln!(" {} clues imported...", count); } } // compute per-word difficulty scores let mut diff_stmt = db .prepare("INSERT INTO word_difficulty (word, score) VALUES (?1, ?2)") .unwrap(); for (word, scores) in &word_day_scores { let avg: f64 = scores.iter().map(|&s| s as f64).sum::() / scores.len() as f64; diff_stmt.execute(rusqlite::params![word, avg]).unwrap(); } db.execute_batch("COMMIT").unwrap(); eprintln!( "imported {} clues for {} words to {}", count, word_day_scores.len(), args.output.display() ); } fn parse_csv_line(line: &str) -> Option<(String, String, String)> { let chars = line.chars(); let mut fields: Vec = Vec::new(); let mut field = String::new(); let mut in_quotes = false; for ch in chars { match ch { '"' => in_quotes = !in_quotes, ',' if !in_quotes => { fields.push(field.clone()); field.clear(); } _ => field.push(ch), } } fields.push(field); if fields.len() >= 3 { Some(( fields[0].trim().to_string(), fields[1].trim().to_string(), fields[2..].join(",").trim().to_string(), )) } else { None } }