Skip to main content

demystify_web/wrap/
mod.rs

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
22/// Each entry: (name, description, model_content, param_content).
23static 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        // Grab the name
211        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        // Create a path for the soon-to-be file
247        let file_path = temp_dir.path().join(file_name);
248
249        // Unwrap the incoming bytes
250        let data = field.bytes().await.context("Failed to read file bytes")?;
251
252        // Open a handle to the file
253        let mut file_handle = File::create(file_path).context("Failed to open file for writing")?;
254
255        // Write the incoming data to the handle
256        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(&param_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}