Skip to main content

demystify_web/wrap/
mod.rs

1use anyhow::Context;
2use axum::body::Body;
3use axum::extract::{Multipart, State};
4use axum::response::{Html, IntoResponse, Redirect, Response};
5use axum_session::{Session, SessionNullPool};
6use once_cell::sync::Lazy;
7use serde::Deserialize;
8
9use std::{fs::File, io::Write, path::PathBuf, sync::Arc};
10
11use anyhow::anyhow;
12
13use crate::util::{
14    self, AppState, ExploreState, SolverSession, get_solver_global, set_solver_global,
15    set_solver_global_session,
16};
17
18use demystify::json::{Problem, Statement};
19use demystify::problem::{
20    self,
21    planner::PuzzlePlanner,
22    solver::PuzzleSolver,
23    solvetree::{MergeStrategy, SolveTree, SolveTreeConfig},
24};
25use demystify::web::puzsvg::PuzzleDraw;
26
27macro_rules! include_model_file {
28    ($path:expr) => {
29        include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/", $path))
30    };
31}
32
33pub struct ExampleInfo {
34    pub name: &'static str,
35    pub description: &'static str,
36    model: &'static str,
37    param: &'static str,
38}
39
40static EXAMPLES: Lazy<[ExampleInfo; 14]> = Lazy::new(|| {
41    [
42        ExampleInfo {
43            name: "Sudoku",
44            description: "Place 1-9 in each row, column and 3x3 box exactly once.",
45            model: include_model_file!("examples/eprime/sudoku.eprime"),
46            param: include_model_file!("examples/eprime/sudoku/puzzlingexample.param"),
47        },
48        ExampleInfo {
49            name: "MiracleSudoku",
50            description: "Sudoku with extra constraints: no two equal digits may be a king or knight's move apart.",
51            model: include_model_file!("examples/eprime/miracle.eprime"),
52            param: include_model_file!("examples/eprime/miracle/original.param"),
53        },
54        ExampleInfo {
55            name: "StarBattle",
56            description: "Place stars in the grid so each row, column and region has exactly one star; stars may not touch.",
57            model: include_model_file!("examples/eprime/star-battle.eprime"),
58            param: include_model_file!("examples/eprime/star-battle/FATAtalkexample.param"),
59        },
60        ExampleInfo {
61            name: "Binairo",
62            description: "Fill the grid with 0s and 1s: equal counts per row/column, no three equal values adjacent.",
63            model: include_model_file!("examples/eprime/binairo.essence"),
64            param: include_model_file!("examples/eprime/binairo/diiscu.param"),
65        },
66        ExampleInfo {
67            name: "Thermometer",
68            description: "Fill thermometers so values increase from bulb to tip.",
69            model: include_model_file!("examples/eprime/thermometer.eprime"),
70            param: include_model_file!("examples/eprime/thermometer/thermometer-1.param"),
71        },
72        ExampleInfo {
73            name: "Futoshiki",
74            description: "Place digits 1-N in each row and column; respect the inequality signs between cells.",
75            model: include_model_file!("examples/eprime/futoshiki.eprime"),
76            param: include_model_file!("examples/eprime/futoshiki/nfutoshiki-1.param"),
77        },
78        ExampleInfo {
79            name: "KillerSudoku",
80            description: "Sudoku where caged groups of cells must sum to a given total; no repeats within a cage.",
81            model: include_model_file!("examples/eprime/killersudoku.eprime"),
82            param: include_model_file!("examples/eprime/killersudoku/killersudoku.param"),
83        },
84        ExampleInfo {
85            name: "Skyscrapers",
86            description: "Place 1-N in each row and column; clues on edges indicate how many buildings are visible.",
87            model: include_model_file!("examples/eprime/skyscrapers.eprime"),
88            param: include_model_file!("examples/eprime/skyscrapers/skyscrapers-1.param"),
89        },
90        ExampleInfo {
91            name: "XSums",
92            description: "Sudoku variant where edge clues equal the sum of the first X digits in that row/column.",
93            model: include_model_file!("examples/eprime/x-sums.eprime"),
94            param: include_model_file!("examples/eprime/x-sums/easy-xsums.param"),
95        },
96        ExampleInfo {
97            name: "Kakurasu",
98            description: "Place marks in a grid so each row and column's marked indices sum to the clue.",
99            model: include_model_file!("examples/eprime/kakurasu.eprime"),
100            param: include_model_file!("examples/eprime/kakurasu/kakurasu.param"),
101        },
102        ExampleInfo {
103            name: "Akari",
104            description: "Place light bulbs so every cell is lit; bulbs may not illuminate each other.",
105            model: include_model_file!("examples/eprime/akari.eprime"),
106            param: include_model_file!("examples/eprime/akari/akari-5x5.param"),
107        },
108        ExampleInfo {
109            name: "Mosaic",
110            description: "Fill each cell black or white; a numbered cell indicates how many of its neighbourhood are black.",
111            model: include_model_file!("examples/eprime/mosaic.eprime"),
112            param: include_model_file!("examples/eprime/mosaic/mosaic-5x5.param"),
113        },
114        ExampleInfo {
115            name: "Nonogram",
116            description: "Fill rows and columns according to clue sequences that describe runs of consecutive filled cells.",
117            model: include_model_file!("examples/eprime/nonogram.eprime"),
118            param: include_model_file!("examples/eprime/nonogram/duck-8x9.param"),
119        },
120        ExampleInfo {
121            name: "Minesweeper",
122            description: "Identify mine locations; numbered cells show exactly how many of their neighbours are mines.",
123            model: include_model_file!("examples/eprime/minesweeper.eprime"),
124            param: include_model_file!("examples/eprime/minesweeper/minesweeper-5x5.param"),
125        },
126    ]
127});
128
129// ─── Template rendering helpers ───
130
131fn render_svg(problem: &Problem) -> String {
132    let pd = PuzzleDraw::new_with_decs(&problem.puzzle.kind, &problem.puzzle.decorations);
133    pd.draw_puzzle(problem).to_string()
134}
135
136fn count_givens(problem: &Problem) -> u32 {
137    problem.puzzle.start_grid.as_ref().map_or(0, |sg| {
138        sg.iter()
139            .flat_map(|row| row.iter())
140            .filter(|c| c.is_some())
141            .count() as u32
142    })
143}
144
145fn count_placed(problem: &Problem) -> u32 {
146    problem
147        .state
148        .as_ref()
149        .and_then(|s| s.knowledge_grid.as_ref())
150        .map_or(0, |kg| {
151            kg.iter()
152                .flat_map(|row| row.iter())
153                .filter(|cell| {
154                    cell.as_ref().is_some_and(|vals| {
155                        vals.len() == 1
156                            && vals[0]
157                                .classes
158                                .as_ref()
159                                .is_some_and(|c| c.contains("litknown") || c.contains("litpos"))
160                    })
161                })
162                .count() as u32
163        })
164}
165
166fn count_candidates(problem: &Problem) -> u32 {
167    problem
168        .state
169        .as_ref()
170        .and_then(|s| s.knowledge_grid.as_ref())
171        .map_or(0, |kg| {
172            kg.iter()
173                .flat_map(|row| row.iter())
174                .filter_map(|cell| cell.as_ref())
175                .map(|vals| vals.len() as u32)
176                .sum()
177        })
178}
179
180fn count_constraints(problem: &Problem) -> u32 {
181    problem
182        .puzzle
183        .constraint_classes
184        .as_ref()
185        .map_or(0, |cc| cc.values().map(|v| v.len() as u32).sum())
186}
187
188/// Extract deduction data from statements for template rendering.
189fn extract_deductions(statements: &[Statement]) -> Vec<tera::Value> {
190    let mut deductions = Vec::new();
191    let mut current_result: Option<String> = None;
192    let mut current_constraints: Vec<String> = Vec::new();
193    let mut current_constraint_classes: Vec<Vec<String>> = Vec::new();
194
195    for stmt in statements {
196        let is_result = stmt.classes.is_empty()
197            || (!stmt.classes.iter().any(|c| c.starts_with("highlight_con")));
198        if is_result {
199            if let Some(result) = current_result.take() {
200                let mut obj = serde_json::Map::new();
201                obj.insert("result".into(), tera::Value::String(result));
202                obj.insert(
203                    "constraints".into(),
204                    tera::Value::Array(
205                        current_constraints
206                            .drain(..)
207                            .map(tera::Value::String)
208                            .collect(),
209                    ),
210                );
211                obj.insert(
212                    "constraint_classes".into(),
213                    tera::Value::Array(
214                        current_constraint_classes
215                            .drain(..)
216                            .map(|cc| {
217                                tera::Value::Array(
218                                    cc.into_iter().map(tera::Value::String).collect(),
219                                )
220                            })
221                            .collect(),
222                    ),
223                );
224                obj.insert("classes".into(), tera::Value::Array(vec![]));
225                deductions.push(tera::Value::Object(obj));
226            }
227            current_result = Some(stmt.content.clone());
228        } else {
229            current_constraints.push(stmt.content.clone());
230            current_constraint_classes.push(stmt.classes.clone());
231        }
232    }
233
234    if let Some(result) = current_result {
235        let mut obj = serde_json::Map::new();
236        obj.insert("result".into(), tera::Value::String(result));
237        obj.insert(
238            "constraints".into(),
239            tera::Value::Array(
240                current_constraints
241                    .into_iter()
242                    .map(tera::Value::String)
243                    .collect(),
244            ),
245        );
246        obj.insert(
247            "constraint_classes".into(),
248            tera::Value::Array(
249                current_constraint_classes
250                    .into_iter()
251                    .map(|cc| tera::Value::Array(cc.into_iter().map(tera::Value::String).collect()))
252                    .collect(),
253            ),
254        );
255        obj.insert("classes".into(), tera::Value::Array(vec![]));
256        deductions.push(tera::Value::Object(obj));
257    }
258
259    deductions
260}
261
262fn build_solver_stage_context(
263    problem: &Problem,
264    round: u32,
265    session: &SolverSession,
266) -> tera::Context {
267    let mut ctx = tera::Context::new();
268    let svg = render_svg(problem);
269    let puzzle_name = &problem.puzzle.kind;
270    let dim = format!("{}x{}", problem.puzzle.width, problem.puzzle.height);
271    let givens = count_givens(problem);
272    let description = problem
273        .state
274        .as_ref()
275        .and_then(|s| s.description.clone())
276        .unwrap_or_default();
277
278    let statements = problem.state.as_ref().and_then(|s| s.statements.as_ref());
279
280    let all_are_legend = statements.is_some_and(|stmts| {
281        !stmts.is_empty()
282            && stmts
283                .iter()
284                .all(|s| s.classes.iter().any(|c| c.starts_with("highlight_con")))
285    });
286
287    let deductions = if all_are_legend {
288        Vec::new()
289    } else {
290        statements.map_or_else(Vec::new, |stmts| extract_deductions(stmts))
291    };
292
293    let legend_items: Vec<tera::Value> = if all_are_legend {
294        statements
295            .unwrap()
296            .iter()
297            .map(|s| {
298                let mut obj = serde_json::Map::new();
299                obj.insert("content".into(), tera::Value::String(s.content.clone()));
300                obj.insert(
301                    "classes".into(),
302                    tera::Value::Array(
303                        s.classes
304                            .iter()
305                            .map(|c| tera::Value::String(c.clone()))
306                            .collect(),
307                    ),
308                );
309                tera::Value::Object(obj)
310            })
311            .collect()
312    } else {
313        Vec::new()
314    };
315
316    let placed = count_placed(problem);
317    let total_cells = (problem.puzzle.width * problem.puzzle.height) as u32;
318    let candidates = count_candidates(problem);
319    let constraint_count = count_constraints(problem);
320    let this_round = problem
321        .state
322        .as_ref()
323        .and_then(|s| s.knowledge_grid.as_ref())
324        .map_or(0u32, |kg| {
325            kg.iter()
326                .flat_map(|row| row.iter())
327                .filter(|cell| {
328                    cell.as_ref().is_some_and(|vals| {
329                        vals.iter()
330                            .any(|v| v.classes.as_ref().is_some_and(|c| c.contains("litpos")))
331                    })
332                })
333                .count() as u32
334        });
335
336    let techniques: Vec<tera::Value> =
337        problem
338            .puzzle
339            .constraint_classes
340            .as_ref()
341            .map_or_else(Vec::new, |cc| {
342                cc.iter()
343                    .map(|(name, instances)| {
344                        let mut obj = serde_json::Map::new();
345                        obj.insert("name".into(), tera::Value::String(name.clone()));
346                        obj.insert(
347                            "count".into(),
348                            tera::Value::Number(serde_json::Number::from(instances.len())),
349                        );
350                        let inst_vals: Vec<tera::Value> = instances
351                            .iter()
352                            .map(|inst| {
353                                let cell_ids = inst
354                                    .cells
355                                    .iter()
356                                    .map(|[r, c]| format!("C_{}_{}", r + 1, c + 1))
357                                    .collect::<Vec<_>>()
358                                    .join(" ");
359                                let mut iobj = serde_json::Map::new();
360                                iobj.insert(
361                                    "description".into(),
362                                    tera::Value::String(inst.description.clone()),
363                                );
364                                iobj.insert("cell_ids".into(), tera::Value::String(cell_ids));
365                                tera::Value::Object(iobj)
366                            })
367                            .collect();
368                        obj.insert("instances".into(), tera::Value::Array(inst_vals));
369                        tera::Value::Object(obj)
370                    })
371                    .collect()
372            });
373
374    ctx.insert("board_svg", &svg);
375    ctx.insert("puzzle_name", puzzle_name);
376    ctx.insert("puzzle_dim", &dim);
377    ctx.insert("givens_count", &givens);
378    ctx.insert("round", &round);
379    ctx.insert("description", &description);
380    ctx.insert("deductions", &deductions);
381    ctx.insert("legend_items", &legend_items);
382    ctx.insert("placed", &placed);
383    ctx.insert("total_cells", &total_cells);
384    ctx.insert("this_round", &this_round);
385    ctx.insert("candidates_left", &candidates);
386    ctx.insert("constraint_count", &constraint_count);
387    ctx.insert("techniques", &techniques);
388
389    let puzzle_info: Vec<String> = problem.puzzle.info.as_ref().cloned().unwrap_or_default();
390    ctx.insert("puzzle_info", &puzzle_info);
391
392    ctx.insert("history_len", &session.history.len());
393
394    ctx.insert("explore_mode", &session.explore_enabled);
395    if let Some(ref explore) = session.explore {
396        ctx.insert("explore_active", &true);
397        ctx.insert("explore_index", &(explore.current_index + 1));
398        ctx.insert("explore_total", &explore.all_muses.len());
399        ctx.insert(
400            "explore_mus_size",
401            &explore.all_muses[explore.current_index].mus_len(),
402        );
403    } else {
404        ctx.insert("explore_active", &false);
405        ctx.insert("explore_index", &0u32);
406        ctx.insert("explore_total", &0u32);
407        ctx.insert("explore_mus_size", &0u32);
408    }
409
410    ctx
411}
412
413fn build_solver_page_context(
414    problem: &Problem,
415    round: u32,
416    session: &SolverSession,
417) -> tera::Context {
418    let mut ctx = build_solver_stage_context(problem, round, session);
419    ctx.insert("view", "solver");
420    ctx.insert("headline", &problem.puzzle.kind);
421    ctx
422}
423
424// ─── Route handlers ───
425
426pub async fn landing(State(state): State<AppState>) -> Result<Html<String>, util::AppError> {
427    let mut ctx = tera::Context::new();
428    ctx.insert("view", "landing");
429    ctx.insert("puzzle_count", &EXAMPLES.len());
430    ctx.insert("model_loaded", &false);
431
432    let examples: Vec<tera::Value> = EXAMPLES
433        .iter()
434        .map(|ex| {
435            let mut obj = serde_json::Map::new();
436            obj.insert("name".into(), tera::Value::String(ex.name.to_string()));
437            obj.insert(
438                "description".into(),
439                tera::Value::String(ex.description.to_string()),
440            );
441            tera::Value::Object(obj)
442        })
443        .collect();
444    ctx.insert("examples", &examples);
445
446    let html = state.tera.render("landing.html", &ctx)?;
447    Ok(Html(html))
448}
449
450pub async fn solver_page(
451    State(state): State<AppState>,
452    session: Session<SessionNullPool>,
453) -> Result<Response, util::AppError> {
454    let solver = match get_solver_global(&session) {
455        Ok(s) => s,
456        Err(_) => {
457            let mut ctx = tera::Context::new();
458            ctx.insert("view", "solver");
459            let html = state.tera.render("no_puzzle.html", &ctx)?;
460            return Ok(Html(html).into_response());
461        }
462    };
463
464    let mut solver = solver.lock().unwrap();
465    let round: u32 = session.get("round").unwrap_or(0);
466    let (problem, _lits) = solver.planner.refresh_problem();
467
468    let ctx = build_solver_page_context(&problem, round, &solver);
469    let html = state.tera.render("solver.html", &ctx)?;
470    Ok(Html(html).into_response())
471}
472
473pub async fn solver_advance(
474    State(state): State<AppState>,
475    session: Session<SessionNullPool>,
476) -> Result<Html<String>, util::AppError> {
477    let solver = get_solver_global(&session)?;
478    let mut solver = solver.lock().unwrap();
479
480    if solver.explore_enabled {
481        return Err(anyhow!("Disable explore mode before advancing").into());
482    }
483
484    let mut round: u32 = session.get("round").unwrap_or(0);
485    round += 1;
486    session.set("round", round);
487
488    solver.snapshot();
489    let (problem, _lits) = solver.planner.solve_step();
490    let ctx = build_solver_stage_context(&problem, round, &solver);
491    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
492    Ok(Html(html))
493}
494
495pub async fn solver_reset(
496    State(state): State<AppState>,
497    session: Session<SessionNullPool>,
498) -> Result<Html<String>, util::AppError> {
499    let solver = get_solver_global(&session)?;
500    let mut solver = solver.lock().unwrap();
501    let round: u32 = session.get("round").unwrap_or(0);
502
503    let (problem, _lits) = solver.planner.refresh_problem();
504    let ctx = build_solver_stage_context(&problem, round, &solver);
505    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
506    Ok(Html(html))
507}
508
509#[derive(Deserialize)]
510pub struct StepParam {
511    step: usize,
512}
513
514pub async fn solver_goto(
515    State(state): State<AppState>,
516    session: Session<SessionNullPool>,
517    form: axum::extract::Form<StepParam>,
518) -> Result<Html<String>, util::AppError> {
519    let solver = get_solver_global(&session)?;
520    let mut solver = solver.lock().unwrap();
521
522    solver.goto_step(form.step)?;
523    session.set("round", form.step as u32);
524
525    let (problem, _lits) = solver.planner.refresh_problem();
526    let ctx = build_solver_stage_context(&problem, form.step as u32, &solver);
527    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
528    Ok(Html(html))
529}
530
531pub async fn solver_difficulties(
532    State(state): State<AppState>,
533    session: Session<SessionNullPool>,
534) -> Result<Html<String>, util::AppError> {
535    let solver = get_solver_global(&session)?;
536    let mut solver = solver.lock().unwrap();
537    let round: u32 = session.get("round").unwrap_or(0);
538
539    let problem = solver.planner.difficulty_problem(false);
540    let ctx = build_solver_stage_context(&problem, round, &solver);
541    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
542    Ok(Html(html))
543}
544
545pub async fn solver_explain(
546    headers: axum::http::header::HeaderMap,
547    State(state): State<AppState>,
548    session: Session<SessionNullPool>,
549) -> Result<Html<String>, util::AppError> {
550    let solver = get_solver_global(&session)?;
551    let mut solver = solver.lock().unwrap();
552
553    let cell = parse_cell_literal(&headers)?;
554
555    if solver.explore_enabled {
556        let round: u32 = session.get("round").unwrap_or(0);
557        return render_explore_explain(&state, &mut solver, round, cell);
558    }
559
560    solver.snapshot();
561    let mut round: u32 = session.get("round").unwrap_or(0);
562    round += 1;
563    session.set("round", round);
564
565    let (problem, _lits) = solver.planner.solve_step_for_literal(cell);
566    let ctx = build_solver_stage_context(&problem, round, &solver);
567    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
568    Ok(Html(html))
569}
570
571// ─── Explore mode ───
572
573fn parse_cell_literal(headers: &axum::http::header::HeaderMap) -> anyhow::Result<Vec<i64>> {
574    let cell = headers
575        .get("x-cell-literal")
576        .context("Missing header: 'X-Cell-Literal'")?;
577    let cell = cell.to_str()?;
578    let cell: Result<Vec<_>, _> = cell.split('_').skip(1).map(str::parse).collect();
579    Ok(cell?)
580}
581
582pub fn parse_cell_literal_pub(headers: &axum::http::header::HeaderMap) -> anyhow::Result<Vec<i64>> {
583    parse_cell_literal(headers)
584}
585
586pub fn build_solver_stage_context_pub(
587    problem: &Problem,
588    round: u32,
589    session: &SolverSession,
590) -> tera::Context {
591    build_solver_stage_context(problem, round, session)
592}
593
594fn render_explore_explain(
595    state: &AppState,
596    solver: &mut SolverSession,
597    round: u32,
598    cell: Vec<i64>,
599) -> Result<Html<String>, util::AppError> {
600    let all_muses = solver.planner.all_muses_for_literal(cell);
601    if all_muses.is_empty() {
602        let (problem, _) = solver.planner.refresh_problem();
603        let ctx = build_solver_stage_context(&problem, round, solver);
604        return Ok(Html(state.tera.render("partials/solver_stage.html", &ctx)?));
605    }
606    let problem = solver.planner.preview_mus(&all_muses[0]);
607    solver.explore = Some(ExploreState {
608        all_muses,
609        current_index: 0,
610    });
611    let ctx = build_solver_stage_context(&problem, round, solver);
612    Ok(Html(state.tera.render("partials/solver_stage.html", &ctx)?))
613}
614
615pub async fn solver_explore_toggle(
616    State(state): State<AppState>,
617    session: Session<SessionNullPool>,
618) -> Result<Html<String>, util::AppError> {
619    let solver = get_solver_global(&session)?;
620    let mut solver = solver.lock().unwrap();
621    let round: u32 = session.get("round").unwrap_or(0);
622
623    solver.explore_enabled = !solver.explore_enabled;
624    if !solver.explore_enabled {
625        solver.explore = None;
626    }
627
628    let (problem, _lits) = solver.planner.refresh_problem();
629    let ctx = build_solver_stage_context(&problem, round, &solver);
630    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
631    Ok(Html(html))
632}
633
634pub async fn solver_explore_navigate(
635    headers: axum::http::header::HeaderMap,
636    State(state): State<AppState>,
637    session: Session<SessionNullPool>,
638) -> Result<Html<String>, util::AppError> {
639    let solver = get_solver_global(&session)?;
640    let mut solver = solver.lock().unwrap();
641    let round: u32 = session.get("round").unwrap_or(0);
642
643    let direction = headers
644        .get("x-explore-direction")
645        .and_then(|v| v.to_str().ok())
646        .unwrap_or("next");
647
648    let explore = solver
649        .explore
650        .as_mut()
651        .context("No explore state — click a cell first")?;
652
653    match direction {
654        "prev" => {
655            if explore.current_index > 0 {
656                explore.current_index -= 1;
657            }
658        }
659        _ => {
660            if explore.current_index + 1 < explore.all_muses.len() {
661                explore.current_index += 1;
662            }
663        }
664    }
665
666    let idx = explore.current_index;
667    let mus = solver.explore.as_ref().unwrap().all_muses[idx].clone();
668    let problem = solver.planner.preview_mus(&mus);
669
670    let ctx = build_solver_stage_context(&problem, round, &solver);
671    let html = state.tera.render("partials/solver_stage.html", &ctx)?;
672    Ok(Html(html))
673}
674
675// ─── Upload / example loading ───
676
677#[derive(Deserialize)]
678pub struct ExampleParams {
679    example_name: String,
680}
681
682#[derive(Deserialize)]
683pub struct SubmitExampleParams {
684    example_name: String,
685    param_content: String,
686}
687
688pub async fn upload_files(
689    State(state): State<AppState>,
690    session: Session<SessionNullPool>,
691    mut multipart: Multipart,
692) -> Result<Response, util::AppError> {
693    let temp_dir = tempfile::Builder::new()
694        .prefix(".demystify-")
695        .tempdir_in(".")
696        .context("Failed to create temporary directory")?;
697
698    let mut model: Option<PathBuf> = None;
699    let mut param: Option<PathBuf> = None;
700    let mut assignment: Option<PathBuf> = None;
701
702    while let Some(field) = multipart
703        .next_field()
704        .await
705        .context("Failed to parse multipart upload")?
706    {
707        let field_name = field.name().unwrap_or("").to_owned();
708        if field_name != "model" && field_name != "params" && field_name != "assignment" {
709            continue;
710        }
711        let form_file_name = field.file_name().context("No filename")?.to_owned();
712
713        // Multipart fields with no chosen file send an empty filename — let
714        // the user skip the optional `assignment` slot without it counting.
715        if form_file_name.is_empty() {
716            continue;
717        }
718
719        let file_name = if field_name == "assignment" {
720            if !form_file_name.ends_with(".json") {
721                return Err(anyhow!(
722                    "Puzzle assignment must be a .json file, got '{form_file_name}'"
723                )
724                .into());
725            }
726            if assignment.is_some() {
727                return Err(anyhow!("Cannot upload two assignment files").into());
728            }
729            assignment = Some("upload.assignment.json".into());
730            "upload.assignment.json"
731        } else if form_file_name.ends_with(".param") || form_file_name.ends_with(".json") {
732            if param.is_some() {
733                return Err(anyhow!("Cannot upload two param files (.param or .json)").into());
734            }
735            if form_file_name.ends_with(".param") {
736                param = Some("upload.param".into());
737                "upload.param"
738            } else {
739                param = Some("upload.json".into());
740                "upload.json"
741            }
742        } else if form_file_name.ends_with(".eprime") || form_file_name.ends_with(".essence") {
743            if model.is_some() {
744                return Err(anyhow!("Can only upload one .eprime or .essence file").into());
745            }
746            if form_file_name.ends_with(".eprime") {
747                model = Some("upload.eprime".into());
748                "upload.eprime"
749            } else {
750                model = Some("upload.essence".into());
751                "upload.essence"
752            }
753        } else {
754            return Err(anyhow!(
755                "Only expecting .param, .json, .eprime or .essence uploads, not '{form_file_name}'"
756            )
757            .into());
758        };
759
760        let file_path = temp_dir.path().join(file_name);
761        let data = field.bytes().await.context("Failed to read file bytes")?;
762        let mut file_handle = File::create(file_path).context("Failed to open file for writing")?;
763        file_handle
764            .write_all(&data)
765            .context("Failed to write data")?;
766    }
767
768    if model.is_none() {
769        return Err(anyhow!("Please upload a model file (.eprime or .essence)").into());
770    }
771    if param.is_none() {
772        return Err(anyhow!("Please upload a parameter file (.param or .json)").into());
773    }
774
775    load_model(&session, &state, temp_dir, model, param, assignment)?;
776    session.set("round", 0u32);
777    Ok(Redirect::to("/solver").into_response())
778}
779
780pub async fn load_example_and_redirect(
781    State(state): State<AppState>,
782    session: Session<SessionNullPool>,
783    form: axum::extract::Form<ExampleParams>,
784) -> Result<Response, util::AppError> {
785    let example_name = &form.example_name;
786
787    let example = EXAMPLES
788        .iter()
789        .find(|e| e.name == *example_name)
790        .context(format!("Example '{example_name}' not found"))?;
791
792    let temp_dir = tempfile::Builder::new()
793        .prefix(".demystify-")
794        .tempdir_in(".")
795        .context("Failed to create temporary directory")?;
796
797    let model_dest = temp_dir.path().join("upload.eprime");
798    std::fs::write(&model_dest, example.model).context("Failed to write model file")?;
799
800    let param_dest = temp_dir.path().join("upload.param");
801    std::fs::write(&param_dest, example.param).context("Failed to write param file")?;
802
803    load_model(
804        &session,
805        &state,
806        temp_dir,
807        Some("upload.eprime".into()),
808        Some("upload.param".into()),
809        None,
810    )?;
811    session.set("round", 0u32);
812    Ok(Redirect::to("/solver").into_response())
813}
814
815pub async fn preview_example(
816    State(state): State<AppState>,
817    form: axum::extract::Form<ExampleParams>,
818) -> Result<Html<String>, util::AppError> {
819    let example_name = &form.example_name;
820
821    let example = EXAMPLES
822        .iter()
823        .find(|e| e.name == *example_name)
824        .context(format!("Example '{example_name}' not found"))?;
825
826    let mut ctx = tera::Context::new();
827    ctx.insert("example_name", example_name);
828    ctx.insert("description", example.description);
829    ctx.insert("param_content", example.param);
830
831    let html = state.tera.render("partials/param_editor.html", &ctx)?;
832    Ok(Html(html))
833}
834
835pub async fn submit_example(
836    State(state): State<AppState>,
837    session: Session<SessionNullPool>,
838    form: axum::extract::Form<SubmitExampleParams>,
839) -> Result<Response, util::AppError> {
840    let example_name = &form.example_name;
841
842    let example = EXAMPLES
843        .iter()
844        .find(|e| e.name == *example_name)
845        .context(format!("Example '{example_name}' not found"))?;
846
847    let temp_dir = tempfile::Builder::new()
848        .prefix(".demystify-")
849        .tempdir_in(".")
850        .context("Failed to create temporary directory")?;
851
852    let model_dest = temp_dir.path().join("upload.eprime");
853    std::fs::write(&model_dest, example.model).context("Failed to write model file")?;
854
855    let param_dest = temp_dir.path().join("upload.param");
856    std::fs::write(&param_dest, &form.param_content).context("Failed to write param file")?;
857
858    load_model(
859        &session,
860        &state,
861        temp_dir,
862        Some("upload.eprime".into()),
863        Some("upload.param".into()),
864        None,
865    )?;
866    session.set("round", 0u32);
867    Ok(Redirect::to("/solver").into_response())
868}
869
870fn load_model(
871    session: &Session<SessionNullPool>,
872    state: &AppState,
873    temp_dir: tempfile::TempDir,
874    model: Option<PathBuf>,
875    param: Option<PathBuf>,
876    assignment: Option<PathBuf>,
877) -> anyhow::Result<()> {
878    let model_path = temp_dir.path().join(model.unwrap());
879    let param_path = temp_dir.path().join(param.unwrap());
880
881    let puz = if let Some(assignment) = assignment {
882        // Mystify-style upload: model declares clue cells as `find` variables
883        // and the assignment file pins them.  Accept either a bare assignment
884        // object or a full mystify output JSON.
885        let assignment_path = temp_dir.path().join(assignment);
886        let raw: serde_json::Value =
887            serde_json::from_reader(std::io::BufReader::new(File::open(&assignment_path)?))
888                .context("assignment file is not valid JSON")?;
889        let assignment_obj = if raw.get("puzzle").is_some() {
890            problem::parse::mystify_puzzle_assignment(&raw)?.clone()
891        } else {
892            raw
893        };
894        problem::parse::parse_essence_with_assignment(
895            &model_path,
896            &param_path,
897            &assignment_obj,
898            Default::default(),
899        )?
900    } else {
901        let puzzle = problem::parse::parse_essence(&model_path, &param_path)?;
902        PuzzleSolver::new(Arc::new(puzzle))?
903    };
904
905    let plan = PuzzlePlanner::new(puz).with_database(state.strategy_db.clone());
906    set_solver_global(session, plan);
907    Ok(())
908}
909
910// ─── Export / import ───
911
912pub async fn solver_export(session: Session<SessionNullPool>) -> Result<Response, util::AppError> {
913    let solver = get_solver_global(&session)?;
914    let solver = solver.lock().unwrap();
915
916    let snapshot = solver.export_snapshot()?;
917    let json = serde_json::to_string_pretty(&snapshot).context("Failed to serialize session")?;
918
919    Ok(Response::builder()
920        .header("Content-Type", "application/json")
921        .header(
922            "Content-Disposition",
923            "attachment; filename=\"demystify-session.json\"",
924        )
925        .body(Body::from(json))
926        .unwrap())
927}
928
929pub async fn solver_import(
930    State(state): State<AppState>,
931    session: Session<SessionNullPool>,
932    mut multipart: Multipart,
933) -> Result<Response, util::AppError> {
934    let mut json_data: Option<Vec<u8>> = None;
935
936    while let Some(field) = multipart
937        .next_field()
938        .await
939        .context("Failed to parse multipart upload")?
940    {
941        let name = field.name().unwrap_or("");
942        if name == "session_file" {
943            let data = field.bytes().await.context("Failed to read file bytes")?;
944            json_data = Some(data.to_vec());
945        }
946    }
947
948    let json_data = json_data.context("No session file uploaded")?;
949    let snapshot: demystify::snapshot::SessionSnapshot =
950        serde_json::from_slice(&json_data).context("Invalid session JSON")?;
951
952    let solver_session = util::import_snapshot_into_session(snapshot, state.strategy_db.clone())?;
953    let round = solver_session.history.len().saturating_sub(1) as u32;
954
955    set_solver_global_session(&session, solver_session);
956    session.set("round", round);
957
958    Ok(Redirect::to("/solver").into_response())
959}
960
961// ─── Legacy compatibility: old JSON full-solve endpoint ───
962
963pub async fn dump_full_solve(
964    session: Session<SessionNullPool>,
965) -> Result<axum::Json<serde_json::Value>, util::AppError> {
966    let solver = get_solver_global(&session)?;
967    let mut solver = solver.lock().unwrap();
968    let solve = solver.planner.quick_solve();
969    Ok(axum::Json(serde_json::value::to_value(solve).unwrap()))
970}
971
972// ─── Solve tree ───
973
974pub async fn solvetree_page(
975    State(state): State<AppState>,
976    session: Session<SessionNullPool>,
977) -> Result<Response, util::AppError> {
978    if get_solver_global(&session).is_err() {
979        let mut ctx = tera::Context::new();
980        ctx.insert("view", "solvetree");
981        let html = state.tera.render("no_puzzle.html", &ctx)?;
982        return Ok(Html(html).into_response());
983    }
984
985    let mut ctx = tera::Context::new();
986    ctx.insert("view", "solvetree");
987    let html = state.tera.render("solvetree.html", &ctx)?;
988    Ok(Html(html).into_response())
989}
990
991#[derive(Deserialize)]
992pub struct SolveTreeParams {
993    merge_strategy: Option<String>,
994    merge_mus_size: Option<usize>,
995}
996
997pub async fn solvetree_build(
998    session: Session<SessionNullPool>,
999    form: axum::extract::Form<SolveTreeParams>,
1000) -> Result<axum::Json<serde_json::Value>, util::AppError> {
1001    let solver = get_solver_global(&session)?;
1002    let puzzle = solver.lock().unwrap().planner.puzzle_arc();
1003
1004    let merge_strategy = match form.merge_strategy.as_deref() {
1005        Some("greedy") => MergeStrategy::Greedy,
1006        Some("minimal") => MergeStrategy::Minimal,
1007        _ => MergeStrategy::None,
1008    };
1009
1010    let config = SolveTreeConfig {
1011        merge_strategy,
1012        merge_mus_size: form.merge_mus_size.unwrap_or(1),
1013        ..SolveTreeConfig::default()
1014    };
1015
1016    let tree = tokio::task::spawn_blocking(move || SolveTree::build(puzzle, &config))
1017        .await
1018        .context("Tree build task panicked")??;
1019
1020    let json = tree.to_d3_json();
1021    Ok(axum::Json(serde_json::to_value(json)?))
1022}
1023
1024/// Dev-only: ask the server to exit.  Pair with a shell loop
1025/// (`while true; do cargo run -p demystify-web --bin demystify-web; done`)
1026/// to get an in-place restart after code changes.  Not linked from the UI.
1027pub async fn quit() -> impl IntoResponse {
1028    eprintln!("/quit hit — exiting so the launcher can restart the server.");
1029    // Spawn the exit so the response has a chance to flush.
1030    tokio::spawn(async {
1031        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1032        std::process::exit(0);
1033    });
1034    "Restarting server.\n"
1035}