Skip to main content

rustoku_lib/
bind.rs

1//! High-level helpers intended for language bindings (`rustoku-wasm`, `rustoku-py`, …).
2//!
3//! Rust consumers of `rustoku-lib` should use the core [`crate::Rustoku`] API directly.
4//! This module deliberately sits at `rustoku_lib::bind` and is **not** re-exported
5//! at the crate root so it stays out of the way.
6//!
7//! These functions wrap the core [`Rustoku`] primitives and return simple, fully-owned
8//! Rust types so that binding crates (`rustoku-wasm`, `rustoku-py`, …) contain no
9//! solving logic of their own – they only marshal results into the target language's
10//! type system.
11
12use crate::core::{BoardGenerator, Difficulty, SolveStep, Symmetry, TechniqueFlags};
13use crate::error::RustokuError;
14use crate::format::format_line;
15use crate::{Rustoku, generate_board_by_difficulty};
16use serde::{Deserialize, Serialize};
17use std::str::FromStr;
18
19// ── Step / Solution types ─────────────────────────────────────────────────────
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct CandidateChange {
23    pub row: usize,
24    pub col: usize,
25    pub before: Vec<u8>,
26    pub after: Vec<u8>,
27    pub removed: Vec<u8>,
28    pub added: Vec<u8>,
29}
30
31/// Flat, serialisable representation of a single solve step.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SolveStepInfo {
34    /// `"placement"` or `"elimination"`.
35    #[serde(rename = "type")]
36    pub step_type: String,
37    pub row: usize,
38    pub col: usize,
39    pub value: u8,
40    /// Human-readable name of the technique used (e.g. `"Naked Singles"`).
41    pub technique: String,
42    pub step_number: u32,
43    pub candidates_eliminated: u32,
44    pub related_cell_count: u8,
45    /// Difficulty metric (0 = trivial, 10 = hardest).
46    pub difficulty_point: u8,
47    #[serde(default)]
48    pub candidate_changes: Vec<CandidateChange>,
49}
50
51impl From<&SolveStep> for SolveStepInfo {
52    fn from(step: &SolveStep) -> Self {
53        match step {
54            SolveStep::Placement {
55                row,
56                col,
57                value,
58                flags,
59                step_number,
60                candidates_eliminated,
61                related_cell_count,
62                difficulty_point,
63            } => SolveStepInfo {
64                step_type: "placement".into(),
65                row: *row,
66                col: *col,
67                value: *value,
68                technique: flags.to_string(),
69                step_number: *step_number,
70                candidates_eliminated: *candidates_eliminated,
71                related_cell_count: *related_cell_count,
72                difficulty_point: *difficulty_point,
73                candidate_changes: Vec::new(),
74            },
75            SolveStep::CandidateElimination {
76                row,
77                col,
78                value,
79                flags,
80                step_number,
81                candidates_eliminated,
82                related_cell_count,
83                difficulty_point,
84            } => SolveStepInfo {
85                step_type: "elimination".into(),
86                row: *row,
87                col: *col,
88                value: *value,
89                technique: flags.to_string(),
90                step_number: *step_number,
91                candidates_eliminated: *candidates_eliminated,
92                related_cell_count: *related_cell_count,
93                difficulty_point: *difficulty_point,
94                candidate_changes: Vec::new(),
95            },
96        }
97    }
98}
99
100/// Structured output from a solve-with-steps operation.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SolveOutput {
103    /// The solved board as an 81-character string.
104    pub board: String,
105    /// Ordered list of steps taken to reach the solution.
106    pub steps: Vec<SolveStepInfo>,
107}
108
109fn diff_candidate_grids(before: &[Vec<Vec<u8>>], after: &[Vec<Vec<u8>>]) -> Vec<CandidateChange> {
110    let mut changes = Vec::new();
111
112    for row in 0..9 {
113        for col in 0..9 {
114            let before_candidates = before
115                .get(row)
116                .and_then(|grid_row| grid_row.get(col))
117                .cloned()
118                .unwrap_or_default();
119            let after_candidates = after
120                .get(row)
121                .and_then(|grid_row| grid_row.get(col))
122                .cloned()
123                .unwrap_or_default();
124
125            if before_candidates == after_candidates {
126                continue;
127            }
128
129            let removed = before_candidates
130                .iter()
131                .copied()
132                .filter(|candidate| !after_candidates.contains(candidate))
133                .collect();
134            let added = after_candidates
135                .iter()
136                .copied()
137                .filter(|candidate| !before_candidates.contains(candidate))
138                .collect();
139
140            changes.push(CandidateChange {
141                row,
142                col,
143                before: before_candidates,
144                after: after_candidates,
145                removed,
146                added,
147            });
148        }
149    }
150
151    changes
152}
153
154fn replay_step_infos_with_candidate_changes(
155    puzzle: &str,
156    steps: &[SolveStep],
157) -> Result<Vec<SolveStepInfo>, RustokuError> {
158    let mut replay = Rustoku::new_from_str(puzzle)?;
159    let mut infos = Vec::with_capacity(steps.len());
160
161    for step in steps {
162        let before = replay.candidate_grid_snapshot();
163        replay.apply_trace_step(step);
164        let after = replay.candidate_grid_snapshot();
165
166        let mut info = SolveStepInfo::from(step);
167        info.candidate_changes = diff_candidate_grids(&before, &after);
168        infos.push(info);
169    }
170
171    Ok(infos)
172}
173
174// ── Helper functions ──────────────────────────────────────────────────────────
175
176/// Maps a difficulty string to cumulative [`TechniqueFlags`].
177///
178/// Each level includes all techniques from lower levels:
179/// `"easy"` ⊂ `"medium"` ⊂ `"hard"` ⊂ `"expert"`.
180fn technique_flags_from_str(s: &str) -> Result<TechniqueFlags, RustokuError> {
181    match s.to_lowercase().as_str() {
182        "easy" => Ok(TechniqueFlags::EASY),
183        "medium" => Ok(TechniqueFlags::EASY | TechniqueFlags::MEDIUM),
184        "hard" => Ok(TechniqueFlags::EASY | TechniqueFlags::MEDIUM | TechniqueFlags::HARD),
185        "expert" => Ok(TechniqueFlags::all()),
186        _ => Err(RustokuError::UnknownDifficulty(s.to_string())),
187    }
188}
189
190/// Solves `puzzle` and returns the first solution as an 81-char string, or `None` if unsolvable.
191pub fn solve_any_str(puzzle: &str) -> Result<Option<String>, RustokuError> {
192    let mut rustoku = Rustoku::new_from_str(puzzle)?;
193    Ok(rustoku.solve_any().map(|s| format_line(&s.board)))
194}
195
196/// Solves `puzzle` and returns **all** solutions as 81-char strings.
197pub fn solve_all_str(puzzle: &str) -> Result<Vec<String>, RustokuError> {
198    let mut rustoku = Rustoku::new_from_str(puzzle)?;
199    Ok(rustoku
200        .solve_all()
201        .into_iter()
202        .map(|s| format_line(&s.board))
203        .collect())
204}
205
206/// Solves `puzzle` using human techniques for the given `difficulty` and returns a full
207/// step trace, or `None` if unsolvable.
208///
209/// `difficulty` is one of `"easy"`, `"medium"`, `"hard"`, `"expert"`.
210pub fn solve_with_steps(
211    puzzle: &str,
212    difficulty: &str,
213) -> Result<Option<SolveOutput>, RustokuError> {
214    let flags = technique_flags_from_str(difficulty)?;
215    let mut rustoku = Rustoku::new_from_str(puzzle)?.with_techniques(flags);
216    match rustoku.solve_any() {
217        Some(solution) => Ok(Some(SolveOutput {
218            board: format_line(&solution.board),
219            steps: replay_step_infos_with_candidate_changes(puzzle, &solution.solve_path.steps)?,
220        })),
221        None => Ok(None),
222    }
223}
224
225/// Returns the candidate digits for every cell as a 9×9 grid.
226///
227/// Filled cells return an empty `Vec`. Empty cells return the digits (1–9)
228/// still possible for that cell given the current constraints.
229pub fn candidates_grid(puzzle: &str) -> Result<Vec<Vec<Vec<u8>>>, RustokuError> {
230    let rustoku = Rustoku::new_from_str(puzzle)?;
231    Ok((0..9)
232        .map(|r| {
233            (0..9)
234                .map(|c| {
235                    if rustoku.board.get(r, c) != 0 {
236                        vec![]
237                    } else {
238                        rustoku.candidates.get_candidates(r, c)
239                    }
240                })
241                .collect()
242        })
243        .collect())
244}
245
246/// Generates a puzzle for the given difficulty string and returns it as an 81-char string.
247pub fn generate_str(difficulty: &str) -> Result<String, RustokuError> {
248    let diff = Difficulty::from_str(difficulty)
249        .map_err(|_| RustokuError::UnknownDifficulty(difficulty.to_string()))?;
250    generate_board_by_difficulty(diff, 100).map(|b| format_line(&b))
251}
252
253/// Returns `true` if `puzzle` is a fully-solved, valid Sudoku board.
254pub fn is_valid_solution(puzzle: &str) -> Result<bool, RustokuError> {
255    Rustoku::new_from_str(puzzle).map(|r| r.is_solved())
256}
257
258/// Advanced generation with specific clues and symmetry.
259pub fn generate_complex_str(
260    symmetry_str: &str,
261    difficulty_str: Option<&str>,
262) -> Result<String, RustokuError> {
263    let symmetry = match symmetry_str.to_lowercase().as_str() {
264        "none" => Symmetry::None,
265        "rotational180" => Symmetry::Rotational180,
266        "rotational90" => Symmetry::Rotational90,
267        "mirrorvertical" => Symmetry::MirrorVertical,
268        "mirrorhorizontal" => Symmetry::MirrorHorizontal,
269        "mirrordiagonal" => Symmetry::MirrorDiagonal,
270        _ => Symmetry::None,
271    };
272
273    let mut builder = BoardGenerator::new().symmetry(symmetry).max_attempts(250);
274
275    if let Some(diff_s) = difficulty_str {
276        let diff = Difficulty::from_str(diff_s)
277            .map_err(|_| RustokuError::UnknownDifficulty(diff_s.to_string()))?;
278        builder = builder.difficulty(diff);
279    }
280
281    builder.generate().map(|b| format_line(&b))
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    #[test]
289    fn test_technique_flags_from_str_valid() {
290        assert!(technique_flags_from_str("easy").is_ok());
291        assert!(technique_flags_from_str("medium").is_ok());
292        assert!(technique_flags_from_str("hard").is_ok());
293        assert!(technique_flags_from_str("expert").is_ok());
294        assert!(technique_flags_from_str("EASY").is_ok()); // case insensitive
295    }
296
297    #[test]
298    fn test_technique_flags_from_str_invalid() {
299        assert!(matches!(
300            technique_flags_from_str("invalid"),
301            Err(RustokuError::UnknownDifficulty(_))
302        ));
303    }
304
305    #[test]
306    fn test_solve_any_str_solvable() {
307        let puzzle =
308            "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......";
309        let result = solve_any_str(puzzle);
310        assert!(result.is_ok());
311        assert!(result.unwrap().is_some());
312    }
313
314    #[test]
315    fn test_solve_any_str_unsolvable() {
316        // Invalid puzzle with conflicts
317        let puzzle =
318            "111111111111111111111111111111111111111111111111111111111111111111111111111111";
319        let result = solve_any_str(puzzle);
320        // All 1s creates a conflict, so it's invalid and returns an error
321        assert!(result.is_err());
322    }
323
324    #[test]
325    fn test_solve_any_str_invalid_input() {
326        let puzzle = "invalid";
327        assert!(solve_any_str(puzzle).is_err());
328    }
329
330    #[test]
331    fn test_solve_all_str() {
332        let puzzle =
333            "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......";
334        let result = solve_all_str(puzzle);
335        assert!(result.is_ok());
336        assert_eq!(result.unwrap().len(), 1);
337    }
338
339    #[test]
340    fn test_solve_with_steps() {
341        let puzzle =
342            "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......";
343        let result = solve_with_steps(puzzle, "expert");
344        assert!(result.is_ok());
345        let output = result.unwrap().unwrap();
346        assert_eq!(output.board.len(), 81);
347        assert!(!output.steps.is_empty());
348        assert!(
349            output
350                .steps
351                .iter()
352                .any(|step| !step.candidate_changes.is_empty())
353        );
354        assert!(output.steps.iter().all(|step| {
355            step.candidate_changes
356                .iter()
357                .all(|change| change.before != change.after)
358        }));
359    }
360
361    #[test]
362    fn test_candidates_grid() {
363        let puzzle =
364            "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......";
365        let result = candidates_grid(puzzle);
366        assert!(result.is_ok());
367        let grid = result.unwrap();
368        assert_eq!(grid.len(), 9);
369        assert_eq!(grid[0].len(), 9);
370        // First cell is 4, so empty candidates
371        assert!(grid[0][0].is_empty());
372        // Some empty cell should have candidates
373        assert!(!grid[0][1].is_empty());
374    }
375
376    #[test]
377    fn test_generate_str_valid() {
378        let result = generate_str("easy");
379        assert!(result.is_ok());
380        let board = result.unwrap();
381        assert_eq!(board.len(), 81);
382        // Should be solvable
383        assert!(solve_any_str(&board).unwrap().is_some());
384    }
385
386    #[test]
387    fn test_generate_str_invalid() {
388        assert!(generate_str("invalid").is_err());
389    }
390
391    #[test]
392    fn test_is_valid_solution_valid() {
393        let solved =
394            "417369825632158947958724316825437169791586432346912758289643571573291684164875293";
395        assert!(is_valid_solution(solved).unwrap());
396    }
397
398    #[test]
399    fn test_is_valid_solution_invalid() {
400        let unsolved =
401            "4.....8.5.3..........7......2.....6.....8.4......1.......6.3.7.5..2.....1.4......";
402        assert!(!is_valid_solution(unsolved).unwrap());
403    }
404
405    #[test]
406    fn test_is_valid_solution_malformed() {
407        assert!(is_valid_solution("invalid").is_err());
408    }
409}