use anyhow::Context;
use axum::{Json, extract::Multipart};
use axum_session::{Session, SessionNullPool};
use once_cell::sync::Lazy;
use serde::Deserialize;
use serde_json::Value;
use std::{fs::File, io::Write, path::PathBuf, sync::Arc};
use anyhow::anyhow;
use crate::util::{self, get_solver_global, set_solver_global};
use demystify::problem::{self, planner::PuzzlePlanner, solver::PuzzleSolver};
macro_rules! include_model_file {
($path:expr) => {
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path))
};
}
static EXAMPLES: Lazy<[(&str, &str, &str, &str); 14]> = Lazy::new(|| {
[
(
"Sudoku",
"Place 1–9 in each row, column and 3×3 box exactly once.",
include_model_file!("examples/eprime/sudoku.eprime"),
include_model_file!("examples/eprime/sudoku/puzzlingexample.param"),
),
(
"MiracleSudoku",
"Sudoku with extra constraints: no two equal digits may be a king or knight's move apart.",
include_model_file!("examples/eprime/miracle.eprime"),
include_model_file!("examples/eprime/miracle/original.param"),
),
(
"StarBattle",
"Place stars in the grid so each row, column and region has exactly one star; stars may not touch.",
include_model_file!("examples/eprime/star-battle.eprime"),
include_model_file!("examples/eprime/star-battle/FATAtalkexample.param"),
),
(
"Binairo",
"Fill the grid with 0s and 1s: equal counts per row/column, no three equal values adjacent.",
include_model_file!("examples/eprime/binairo.essence"),
include_model_file!("examples/eprime/binairo/diiscu.param"),
),
(
"Thermometer",
"Fill thermometers so values increase from bulb to tip.",
include_model_file!("examples/eprime/thermometer.eprime"),
include_model_file!("examples/eprime/thermometer/thermometer-1.param"),
),
(
"Futoshiki",
"Place digits 1–N in each row and column; respect the inequality signs between cells.",
include_model_file!("examples/eprime/futoshiki.eprime"),
include_model_file!("examples/eprime/futoshiki/nfutoshiki-1.param"),
),
(
"KillerSudoku",
"Sudoku where caged groups of cells must sum to a given total; no repeats within a cage.",
include_model_file!("examples/eprime/killersudoku.eprime"),
include_model_file!("examples/eprime/killersudoku/killersudoku.param"),
),
(
"Skyscrapers",
"Place 1–N in each row and column; clues on edges indicate how many 'buildings' are visible.",
include_model_file!("examples/eprime/skyscrapers.eprime"),
include_model_file!("examples/eprime/skyscrapers/skyscrapers-1.param"),
),
(
"XSums",
"Sudoku variant where edge clues equal the sum of the first X digits in that row/column (X is the first digit).",
include_model_file!("examples/eprime/x-sums.eprime"),
include_model_file!("examples/eprime/x-sums/easy-xsums.param"),
),
(
"Kakurasu",
"Place marks in a grid so each row and column's marked-column (or marked-row) indices sum to the clue.",
include_model_file!("examples/eprime/kakurasu.eprime"),
include_model_file!("examples/eprime/kakurasu/kakurasu.param"),
),
(
"Akari",
"Place light bulbs so every cell is lit; bulbs may not illuminate each other; numbered cells must have exactly that many adjacent bulbs.",
include_model_file!("examples/eprime/akari.eprime"),
include_model_file!("examples/eprime/akari/akari-5x5.param"),
),
(
"Mosaic",
"Fill each cell black or white; a numbered cell indicates how many of its 3×3 neighbourhood (including itself) are black.",
include_model_file!("examples/eprime/mosaic.eprime"),
include_model_file!("examples/eprime/mosaic/mosaic-5x5.param"),
),
(
"Nonogram",
"Fill rows and columns according to clue sequences that describe runs of consecutive filled cells.",
include_model_file!("examples/eprime/nonogram.eprime"),
include_model_file!("examples/eprime/nonogram/duck-8x9.param"),
),
(
"Minesweeper",
"Identify mine locations; numbered cells show exactly how many of their neighbours are mines.",
include_model_file!("examples/eprime/minesweeper.eprime"),
include_model_file!("examples/eprime/minesweeper/minesweeper-5x5.param"),
),
]
});
pub async fn dump_full_solve(
session: Session<SessionNullPool>,
) -> Result<Json<Value>, util::AppError> {
let solver = get_solver_global(&session)?;
let mut solver = solver.lock().unwrap();
let solve = solver.quick_solve();
Ok(Json(serde_json::value::to_value(solve).unwrap()))
}
pub async fn best_next_step(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
let solver = get_solver_global(&session)?;
let mut solver = solver.lock().unwrap();
let (solve, lits) = solver.quick_solve_html_step();
solver.mark_lits_as_deduced(&lits);
if solve.is_empty() {
Ok("Please upload a puzzle or select an example to begin.".to_string())
} else {
Ok(solve)
}
}
pub async fn get_difficulties(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
let solver = get_solver_global(&session)?;
let mut solver = solver.lock().unwrap();
let solve = solver.quick_generate_html_difficulties();
Ok(solve)
}
pub async fn refresh(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
let solver = get_solver_global(&session)?;
let mut solver = solver.lock().unwrap();
let (solve, _) = solver.refresh_html_step();
Ok(solve)
}
pub async fn click_literal(
headers: axum::http::header::HeaderMap,
session: Session<SessionNullPool>,
) -> Result<String, util::AppError> {
let solver = get_solver_global(&session)?;
let mut solver = solver.lock().unwrap();
let cell = headers
.get("hx-trigger")
.context("Missing header: 'hx-trigger'")?;
let cell = cell.to_str()?;
let cell: Result<Vec<_>, _> = cell.split('_').skip(1).map(str::parse).collect();
let cell = cell?;
session.set("click_cell", &cell);
let (html, lits) = solver.quick_solve_html_step_for_literal(cell);
let lidx_lits: Vec<_> = lits.iter().map(|x| x.lidx()).collect();
session.set("lidx_lits", &lidx_lits);
Ok(html)
}
pub async fn upload_files(
session: Session<SessionNullPool>,
mut multipart: Multipart,
) -> Result<String, util::AppError> {
let temp_dir = tempfile::Builder::new()
.prefix(".demystify-")
.tempdir_in(".")
.context("Failed to create temporary directory")?;
let mut model: Option<PathBuf> = None;
let mut param: Option<PathBuf> = None;
while let Some(field) = multipart
.next_field()
.await
.context("Failed to parse multipart upload")?
{
if field.name().unwrap() != "model" && field.name().unwrap() != "parameter" {
return Err(anyhow!(
"Form malformed -- should contain 'model' and 'parameter', but it contains '{}'",
field.name().unwrap()
)
.into());
}
let form_file_name = field.file_name().context("No filename")?;
eprintln!("Got file '{form_file_name}'!");
let file_name = if form_file_name.ends_with(".param") || form_file_name.ends_with(".json") {
if param.is_some() {
return Err(anyhow!("Cannot upload two param files (.param or .json)").into());
}
if form_file_name.ends_with(".param") {
param = Some("upload.param".into());
"upload.param"
} else {
param = Some("upload.json".into());
"upload.json"
}
} else if form_file_name.ends_with(".eprime") || form_file_name.ends_with(".essence") {
if model.is_some() {
return Err(anyhow!("Can only upload one .eprime or .essence file").into());
}
if form_file_name.ends_with(".eprime") {
model = Some("upload.eprime".into());
"upload.eprime"
} else {
model = Some("upload.essence".into());
"upload.essence"
}
} else {
return Err(anyhow!(
"Only expecting .param, .json, .eprime or .essence uploads, not '{}'",
form_file_name
)
.into());
};
let file_path = temp_dir.path().join(file_name);
let data = field.bytes().await.context("Failed to read file bytes")?;
let mut file_handle = File::create(file_path).context("Failed to open file for writing")?;
file_handle
.write_all(&data)
.context("Failed to write data!")?;
}
if model.is_none() {
return Ok(r###"
<div class="alert alert-danger">
<h4>Upload Error</h4>
<p>Please upload a model file (.eprime or .essence)</p>
</div>
"###
.to_string());
}
if param.is_none() {
return Ok(r###"
<div class="alert alert-danger">
<h4>Upload Error</h4>
<p>Please upload a parameter file (.param or .json)</p>
</div>
"###
.to_string());
}
match load_model(&session, temp_dir, model, param) {
Ok(_) => refresh(session).await,
Err(e) => Ok(format!(
r###"
<div class="alert alert-danger">
<h4>Failed to upload puzzle</h4>
<pre class="text-danger">{e:#}</pre>
<p>Please check your files and try again.</p>
</div>
"###
)),
}
}
#[derive(Deserialize)]
pub struct ExampleParams {
example_name: String,
}
#[derive(Deserialize)]
pub struct SubmitExampleParams {
param_content: String,
example_name: String,
}
pub async fn load_example(
_session: Session<SessionNullPool>,
form: axum::extract::Form<ExampleParams>,
) -> Result<String, util::AppError> {
let example_name = form.example_name.clone();
let (_, description, _, param_content) = EXAMPLES
.iter()
.find(|(name, _, _, _)| *name == example_name)
.copied()
.context(format!("Example '{example_name}' not found"))?;
Ok(format!(
r###"
<h5>Edit Parameters for {example_name}</h5>
<p class="text-muted small">{description}</p>
<form id="paramForm" hx-post="/submitExample" hx-target="#mainSpace">
<input type="hidden" name="example_name" value="{example_name}">
<textarea name="param_content" class="form-control" rows="15" style="font-family: monospace;">{param_content}</textarea>
<button type="submit" class="btn btn-primary mt-2" hx-indicator="#indicator">
Submit Parameters
</button>
</form>
"###
))
}
pub async fn get_example_names() -> String {
let options = EXAMPLES
.iter()
.map(|(name, desc, _, _)| {
format!("<option value=\"{name}\" title=\"{desc}\">{name}</option>")
})
.collect::<Vec<_>>()
.join("");
r###"<form id="exampleForm" hx-post="/loadExample" hx-target="#exampleParams">
<select name="example_name" class="form-select" required>
"###
.to_owned()
+ &options
+ r###"
</select>
<button type="submit" class="btn btn-primary mt-3" hx-indicator="#indicator">
Load Example
</button>
</form>
"###
}
pub async fn submit_example(
session: Session<SessionNullPool>,
form: axum::extract::Form<SubmitExampleParams>,
) -> Result<String, util::AppError> {
let example_name = form.example_name.clone();
let param_content = form.param_content.clone();
let model_content = EXAMPLES
.iter()
.find(|(name, _, _, _)| *name == example_name)
.map(|(_, _, content, _)| *content)
.context(format!("Example '{example_name}' not found"))?;
let temp_dir = tempfile::Builder::new()
.prefix(".demystify-")
.tempdir_in(".")
.context("Failed to create temporary directory")?;
let model_dest = temp_dir.path().join("upload.eprime");
std::fs::write(&model_dest, model_content).context("Failed to write model file")?;
let param_dest = temp_dir.path().join("upload.param");
std::fs::write(¶m_dest, param_content).context("Failed to write param file")?;
match load_model(
&session,
temp_dir,
Some("upload.eprime".into()),
Some("upload.param".into()),
) {
Ok(_) => refresh(session).await,
Err(e) => Ok(format!(
r###"
<div class="alert alert-danger">
<h4>Failed to load puzzle</h4>
<pre class="text-danger">{e:#}</pre>
<p>Please check your parameter file and try again.</p>
</div>
"###
)),
}
}
fn load_model(
session: &Session<SessionNullPool>,
temp_dir: tempfile::TempDir,
model: Option<PathBuf>,
param: Option<PathBuf>,
) -> anyhow::Result<()> {
let puzzle = problem::parse::parse_essence(
&temp_dir.path().join(model.unwrap()),
&temp_dir.path().join(param.unwrap()),
)?;
let puzzle = Arc::new(puzzle);
let puz = PuzzleSolver::new(puzzle)?;
let plan = PuzzlePlanner::new(puz);
set_solver_global(session, plan);
Ok(())
}