1use anyhow::Context;
2use axum::{Json, extract::Multipart};
3use axum_session::{Session, SessionNullPool};
4use once_cell::sync::Lazy;
5use serde::Deserialize;
6use serde_json::Value;
7
8use std::{fs::File, io::Write, path::PathBuf, sync::Arc};
9
10use anyhow::anyhow;
11
12use crate::util::{self, get_solver_global, set_solver_global};
13
14use demystify::problem::{self, planner::PuzzlePlanner, solver::PuzzleSolver};
15
16macro_rules! include_model_file {
17 ($path:expr) => {
18 include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path))
19 };
20}
21
22static EXAMPLES: Lazy<[(&str, &str, &str, &str); 14]> = Lazy::new(|| {
24 [
25 (
26 "Sudoku",
27 "Place 1–9 in each row, column and 3×3 box exactly once.",
28 include_model_file!("examples/eprime/sudoku.eprime"),
29 include_model_file!("examples/eprime/sudoku/puzzlingexample.param"),
30 ),
31 (
32 "MiracleSudoku",
33 "Sudoku with extra constraints: no two equal digits may be a king or knight's move apart.",
34 include_model_file!("examples/eprime/miracle.eprime"),
35 include_model_file!("examples/eprime/miracle/original.param"),
36 ),
37 (
38 "StarBattle",
39 "Place stars in the grid so each row, column and region has exactly one star; stars may not touch.",
40 include_model_file!("examples/eprime/star-battle.eprime"),
41 include_model_file!("examples/eprime/star-battle/FATAtalkexample.param"),
42 ),
43 (
44 "Binairo",
45 "Fill the grid with 0s and 1s: equal counts per row/column, no three equal values adjacent.",
46 include_model_file!("examples/eprime/binairo.essence"),
47 include_model_file!("examples/eprime/binairo/diiscu.param"),
48 ),
49 (
50 "Thermometer",
51 "Fill thermometers so values increase from bulb to tip.",
52 include_model_file!("examples/eprime/thermometer.eprime"),
53 include_model_file!("examples/eprime/thermometer/thermometer-1.param"),
54 ),
55 (
56 "Futoshiki",
57 "Place digits 1–N in each row and column; respect the inequality signs between cells.",
58 include_model_file!("examples/eprime/futoshiki.eprime"),
59 include_model_file!("examples/eprime/futoshiki/nfutoshiki-1.param"),
60 ),
61 (
62 "KillerSudoku",
63 "Sudoku where caged groups of cells must sum to a given total; no repeats within a cage.",
64 include_model_file!("examples/eprime/killersudoku.eprime"),
65 include_model_file!("examples/eprime/killersudoku/killersudoku.param"),
66 ),
67 (
68 "Skyscrapers",
69 "Place 1–N in each row and column; clues on edges indicate how many 'buildings' are visible.",
70 include_model_file!("examples/eprime/skyscrapers.eprime"),
71 include_model_file!("examples/eprime/skyscrapers/skyscrapers-1.param"),
72 ),
73 (
74 "XSums",
75 "Sudoku variant where edge clues equal the sum of the first X digits in that row/column (X is the first digit).",
76 include_model_file!("examples/eprime/x-sums.eprime"),
77 include_model_file!("examples/eprime/x-sums/easy-xsums.param"),
78 ),
79 (
80 "Kakurasu",
81 "Place marks in a grid so each row and column's marked-column (or marked-row) indices sum to the clue.",
82 include_model_file!("examples/eprime/kakurasu.eprime"),
83 include_model_file!("examples/eprime/kakurasu/kakurasu.param"),
84 ),
85 (
86 "Akari",
87 "Place light bulbs so every cell is lit; bulbs may not illuminate each other; numbered cells must have exactly that many adjacent bulbs.",
88 include_model_file!("examples/eprime/akari.eprime"),
89 include_model_file!("examples/eprime/akari/akari-5x5.param"),
90 ),
91 (
92 "Mosaic",
93 "Fill each cell black or white; a numbered cell indicates how many of its 3×3 neighbourhood (including itself) are black.",
94 include_model_file!("examples/eprime/mosaic.eprime"),
95 include_model_file!("examples/eprime/mosaic/mosaic-5x5.param"),
96 ),
97 (
98 "Nonogram",
99 "Fill rows and columns according to clue sequences that describe runs of consecutive filled cells.",
100 include_model_file!("examples/eprime/nonogram.eprime"),
101 include_model_file!("examples/eprime/nonogram/duck-8x9.param"),
102 ),
103 (
104 "Minesweeper",
105 "Identify mine locations; numbered cells show exactly how many of their neighbours are mines.",
106 include_model_file!("examples/eprime/minesweeper.eprime"),
107 include_model_file!("examples/eprime/minesweeper/minesweeper-5x5.param"),
108 ),
109 ]
110});
111
112pub async fn dump_full_solve(
113 session: Session<SessionNullPool>,
114) -> Result<Json<Value>, util::AppError> {
115 let solver = get_solver_global(&session)?;
116
117 let mut solver = solver.lock().unwrap();
118
119 let solve = solver.quick_solve();
120
121 Ok(Json(serde_json::value::to_value(solve).unwrap()))
122}
123
124pub async fn best_next_step(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
125 let solver = get_solver_global(&session)?;
126
127 let mut solver = solver.lock().unwrap();
128
129 let (solve, lits) = solver.quick_solve_html_step();
130
131 solver.mark_lits_as_deduced(&lits);
132
133 if solve.is_empty() {
134 Ok("Please upload a puzzle or select an example to begin.".to_string())
135 } else {
136 Ok(solve)
137 }
138}
139
140pub async fn get_difficulties(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
141 let solver = get_solver_global(&session)?;
142
143 let mut solver = solver.lock().unwrap();
144
145 let solve = solver.quick_generate_html_difficulties();
146
147 Ok(solve)
148}
149
150pub async fn refresh(session: Session<SessionNullPool>) -> Result<String, util::AppError> {
151 let solver = get_solver_global(&session)?;
152
153 let mut solver = solver.lock().unwrap();
154
155 let (solve, _) = solver.refresh_html_step();
156
157 Ok(solve)
158}
159
160pub async fn click_literal(
161 headers: axum::http::header::HeaderMap,
162 session: Session<SessionNullPool>,
163) -> Result<String, util::AppError> {
164 let solver = get_solver_global(&session)?;
165
166 let mut solver = solver.lock().unwrap();
167
168 let cell = headers
169 .get("hx-trigger")
170 .context("Missing header: 'hx-trigger'")?;
171 let cell = cell.to_str()?;
172 let cell: Result<Vec<_>, _> = cell.split('_').skip(1).map(str::parse).collect();
173 let cell = cell?;
174
175 session.set("click_cell", &cell);
176
177 let (html, lits) = solver.quick_solve_html_step_for_literal(cell);
178
179 let lidx_lits: Vec<_> = lits.iter().map(|x| x.lidx()).collect();
180 session.set("lidx_lits", &lidx_lits);
181
182 Ok(html)
183}
184
185pub async fn upload_files(
186 session: Session<SessionNullPool>,
187 mut multipart: Multipart,
188) -> Result<String, util::AppError> {
189 let temp_dir = tempfile::Builder::new()
190 .prefix(".demystify-")
191 .tempdir_in(".")
192 .context("Failed to create temporary directory")?;
193
194 let mut model: Option<PathBuf> = None;
195 let mut param: Option<PathBuf> = None;
196
197 while let Some(field) = multipart
198 .next_field()
199 .await
200 .context("Failed to parse multipart upload")?
201 {
202 if field.name().unwrap() != "model" && field.name().unwrap() != "parameter" {
203 return Err(anyhow!(
204 "Form malformed -- should contain 'model' and 'parameter', but it contains '{}'",
205 field.name().unwrap()
206 )
207 .into());
208 }
209
210 let form_file_name = field.file_name().context("No filename")?;
212
213 eprintln!("Got file '{form_file_name}'!");
214
215 let file_name = if form_file_name.ends_with(".param") || form_file_name.ends_with(".json") {
216 if param.is_some() {
217 return Err(anyhow!("Cannot upload two param files (.param or .json)").into());
218 }
219
220 if form_file_name.ends_with(".param") {
221 param = Some("upload.param".into());
222 "upload.param"
223 } else {
224 param = Some("upload.json".into());
225 "upload.json"
226 }
227 } else if form_file_name.ends_with(".eprime") || form_file_name.ends_with(".essence") {
228 if model.is_some() {
229 return Err(anyhow!("Can only upload one .eprime or .essence file").into());
230 }
231 if form_file_name.ends_with(".eprime") {
232 model = Some("upload.eprime".into());
233 "upload.eprime"
234 } else {
235 model = Some("upload.essence".into());
236 "upload.essence"
237 }
238 } else {
239 return Err(anyhow!(
240 "Only expecting .param, .json, .eprime or .essence uploads, not '{}'",
241 form_file_name
242 )
243 .into());
244 };
245
246 let file_path = temp_dir.path().join(file_name);
248
249 let data = field.bytes().await.context("Failed to read file bytes")?;
251
252 let mut file_handle = File::create(file_path).context("Failed to open file for writing")?;
254
255 file_handle
257 .write_all(&data)
258 .context("Failed to write data!")?;
259 }
260
261 if model.is_none() {
262 return Ok(r###"
263 <div class="alert alert-danger">
264 <h4>Upload Error</h4>
265 <p>Please upload a model file (.eprime or .essence)</p>
266 </div>
267 "###
268 .to_string());
269 }
270
271 if param.is_none() {
272 return Ok(r###"
273 <div class="alert alert-danger">
274 <h4>Upload Error</h4>
275 <p>Please upload a parameter file (.param or .json)</p>
276 </div>
277 "###
278 .to_string());
279 }
280
281 match load_model(&session, temp_dir, model, param) {
282 Ok(_) => refresh(session).await,
283 Err(e) => Ok(format!(
284 r###"
285 <div class="alert alert-danger">
286 <h4>Failed to upload puzzle</h4>
287 <pre class="text-danger">{e:#}</pre>
288 <p>Please check your files and try again.</p>
289 </div>
290 "###
291 )),
292 }
293}
294
295#[derive(Deserialize)]
296pub struct ExampleParams {
297 example_name: String,
298}
299
300#[derive(Deserialize)]
301pub struct SubmitExampleParams {
302 param_content: String,
303 example_name: String,
304}
305
306pub async fn load_example(
307 _session: Session<SessionNullPool>,
308 form: axum::extract::Form<ExampleParams>,
309) -> Result<String, util::AppError> {
310 let example_name = form.example_name.clone();
311
312 let (_, description, _, param_content) = EXAMPLES
313 .iter()
314 .find(|(name, _, _, _)| *name == example_name)
315 .copied()
316 .context(format!("Example '{example_name}' not found"))?;
317
318 Ok(format!(
319 r###"
320 <h5>Edit Parameters for {example_name}</h5>
321 <p class="text-muted small">{description}</p>
322 <form id="paramForm" hx-post="/submitExample" hx-target="#mainSpace">
323 <input type="hidden" name="example_name" value="{example_name}">
324 <textarea name="param_content" class="form-control" rows="15" style="font-family: monospace;">{param_content}</textarea>
325 <button type="submit" class="btn btn-primary mt-2" hx-indicator="#indicator">
326 Submit Parameters
327 </button>
328 </form>
329 "###
330 ))
331}
332
333pub async fn get_example_names() -> String {
334 let options = EXAMPLES
335 .iter()
336 .map(|(name, desc, _, _)| {
337 format!("<option value=\"{name}\" title=\"{desc}\">{name}</option>")
338 })
339 .collect::<Vec<_>>()
340 .join("");
341
342 r###"<form id="exampleForm" hx-post="/loadExample" hx-target="#exampleParams">
343 <select name="example_name" class="form-select" required>
344 "###
345 .to_owned()
346 + &options
347 + r###"
348 </select>
349 <button type="submit" class="btn btn-primary mt-3" hx-indicator="#indicator">
350 Load Example
351 </button>
352 </form>
353 "###
354}
355
356pub async fn submit_example(
357 session: Session<SessionNullPool>,
358 form: axum::extract::Form<SubmitExampleParams>,
359) -> Result<String, util::AppError> {
360 let example_name = form.example_name.clone();
361 let param_content = form.param_content.clone();
362
363 let model_content = EXAMPLES
364 .iter()
365 .find(|(name, _, _, _)| *name == example_name)
366 .map(|(_, _, content, _)| *content)
367 .context(format!("Example '{example_name}' not found"))?;
368
369 let temp_dir = tempfile::Builder::new()
370 .prefix(".demystify-")
371 .tempdir_in(".")
372 .context("Failed to create temporary directory")?;
373
374 let model_dest = temp_dir.path().join("upload.eprime");
375 std::fs::write(&model_dest, model_content).context("Failed to write model file")?;
376
377 let param_dest = temp_dir.path().join("upload.param");
378 std::fs::write(¶m_dest, param_content).context("Failed to write param file")?;
379
380 match load_model(
381 &session,
382 temp_dir,
383 Some("upload.eprime".into()),
384 Some("upload.param".into()),
385 ) {
386 Ok(_) => refresh(session).await,
387 Err(e) => Ok(format!(
388 r###"
389 <div class="alert alert-danger">
390 <h4>Failed to load puzzle</h4>
391 <pre class="text-danger">{e:#}</pre>
392 <p>Please check your parameter file and try again.</p>
393 </div>
394 "###
395 )),
396 }
397}
398
399fn load_model(
400 session: &Session<SessionNullPool>,
401 temp_dir: tempfile::TempDir,
402 model: Option<PathBuf>,
403 param: Option<PathBuf>,
404) -> anyhow::Result<()> {
405 let puzzle = problem::parse::parse_essence(
406 &temp_dir.path().join(model.unwrap()),
407 &temp_dir.path().join(param.unwrap()),
408 )?;
409 let puzzle = Arc::new(puzzle);
410 let puz = PuzzleSolver::new(puzzle)?;
411 let plan = PuzzlePlanner::new(puz);
412 set_solver_global(session, plan);
413 Ok(())
414}