the cli driver

This commit is contained in:
2025-07-12 01:42:41 +03:00
parent 92d6223e27
commit 1d0ffc1d56
+367
View File
@@ -0,0 +1,367 @@
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<CliDifficulty>,
#[arg(long, value_enum, default_value = "rotational180")]
symmetry: CliSymmetry,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long)]
seed: Option<u64>,
#[arg(long, default_value = "0.22")]
density: f64,
#[arg(long, default_value = "../data/wordlists/enable.txt")]
wordlist: PathBuf,
#[arg(long)]
blocklist: Option<PathBuf>,
#[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<CliSymmetry> 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<u32> {
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<String, Vec<u32>> = 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::<f64>() / 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<String> = 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
}
}