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
129fn 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
188fn 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
424pub 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
571fn 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#[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 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(¶m_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(¶m_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 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 ¶m_path,
897 &assignment_obj,
898 Default::default(),
899 )?
900 } else {
901 let puzzle = problem::parse::parse_essence(&model_path, ¶m_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
910pub 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
961pub 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
972pub 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
1024pub async fn quit() -> impl IntoResponse {
1028 eprintln!("/quit hit — exiting so the launcher can restart the server.");
1029 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}