Skip to main content

rustoku_lib/core/
mod.rs

1//! Core module for the Rustoku solver and generator.
2//!
3//! This module includes the `Rustoku` struct for representing and solving Sudoku puzzles using
4//! backtracking and Minimum Remaining Values (MRV). It provides functionality for solving
5//! puzzles and checking solutions.
6//!
7//! This module also includes a function to generate new Sudoku puzzles with a specified
8//! number of clues, ensuring that the generated puzzle has a unique solution.
9
10mod board;
11mod candidates;
12mod masks;
13mod solution;
14mod techniques;
15
16pub use board::Board;
17pub use candidates::Candidates;
18pub use masks::Masks;
19pub use solution::{Solution, SolvePath, SolveStep};
20pub use techniques::flags::TechniqueFlags;
21
22use crate::error::RustokuError;
23use rand::prelude::SliceRandom;
24use rand::rng;
25use techniques::TechniquePropagator;
26
27/// Solver primitive that uses backtracking and bitmasking for constraints.
28///
29/// This struct supports the ability to:
30/// - Initialize from a 2D array, a flat byte array, or a string representation
31/// - Solve a Sudoku puzzle using backtracking with Minimum Remaining Values (MRV)
32/// - Generate a Sudoku puzzle with a unique solution based on the number of clues specified
33/// - Check if a Sudoku puzzle is solved correctly
34///
35/// # Examples
36///
37/// Solve a Sudoku puzzle:
38/// ```
39/// use rustoku_lib::Rustoku;
40/// let puzzle = "530070000600195000098000060800060003400803001700020006060000280000419005000080079";
41/// let mut rustoku = Rustoku::new_from_str(puzzle).unwrap();
42/// assert!(rustoku.solve_any().is_some());
43/// ```
44///
45/// Generate a Sudoku puzzle:
46/// ```
47/// use rustoku_lib::{Rustoku, generate_board};
48/// let board = generate_board(30).unwrap();
49/// let solution = Rustoku::new(board).unwrap().solve_all();
50/// assert_eq!(solution.len(), 1);
51/// ```
52///
53/// Check if a Sudoku puzzle is solved:
54/// ```
55/// use rustoku_lib::Rustoku;
56/// let puzzle = "534678912672195348198342567859761423426853791713924856961537284287419635345286179";
57/// let rustoku = Rustoku::new_from_str(puzzle).unwrap();
58/// assert!(rustoku.is_solved());
59/// ```
60#[derive(Debug, Copy, Clone)]
61pub struct Rustoku {
62    /// The current state of the Sudoku board.
63    pub board: Board,
64    /// Bitmasks that check if a cell is safe in a row, column and box.
65    pub masks: Masks,
66    /// Candidate cache from computing the bitmasks.
67    pub candidates: Candidates,
68    /// Techniques used during the initial phase of solving.
69    pub techniques: TechniqueFlags,
70}
71
72impl Rustoku {
73    /// Constructs a new `Rustoku` instance from an initial `Board`.
74    pub fn new(initial_board: Board) -> Result<Self, RustokuError> {
75        let board = initial_board; // Now takes a Board directly
76        let mut masks = Masks::new();
77        let mut candidates = Candidates::new();
78
79        // Initialize masks and check for duplicates based on the provided board
80        for r in 0..9 {
81            for c in 0..9 {
82                let num = board.get(r, c);
83                if num != 0 {
84                    if !masks.is_safe(r, c, num) {
85                        return Err(RustokuError::DuplicateValues);
86                    }
87                    masks.add_number(r, c, num);
88                }
89            }
90        }
91
92        // Initialize the candidates cache for empty cells based on initial masks and board
93        for r in 0..9 {
94            for c in 0..9 {
95                if board.is_empty(r, c) {
96                    candidates.set(r, c, masks.compute_candidates_mask_for_cell(r, c));
97                }
98            }
99        }
100
101        Ok(Self {
102            board,
103            masks,
104            candidates,
105            techniques: TechniqueFlags::EASY, // Default
106        })
107    }
108
109    /// Start building a configured `Rustoku` via a builder pattern.
110    pub fn builder() -> RustokuBuilder {
111        RustokuBuilder::new()
112    }
113
114    /// Constructs a new `Rustoku` instance from a string representation of the board.
115    pub fn new_from_str(s: &str) -> Result<Self, RustokuError> {
116        let board = Board::try_from(s)?;
117        Self::new(board)
118    }
119
120    /// Returns the existing Rustoku instance, with modified techniques.
121    pub fn with_techniques(mut self, techniques: TechniqueFlags) -> Self {
122        self.techniques = techniques;
123        self
124    }
125
126    /// Extracts candidate numbers (1-9) from a bitmask into a Vec.
127    fn candidates_from_mask(mask: u16) -> Vec<u8> {
128        let mut nums = Vec::with_capacity(mask.count_ones() as usize);
129        for v in 1..=9u8 {
130            if mask & (1 << (v - 1)) != 0 {
131                nums.push(v);
132            }
133        }
134        nums
135    }
136
137    /// Helper for solver to find the next empty cell (MRV).
138    fn find_next_empty_cell(&self) -> Option<(usize, usize)> {
139        let mut min = (10, None); // Min candidates, (r, c)
140        for (r, c) in self.board.iter_empty_cells() {
141            let count = self.candidates.get(r, c).count_ones() as u8;
142            if count < min.0 {
143                min = (count, Some((r, c)));
144                if count == 1 {
145                    return min.1;
146                }
147            }
148        }
149        min.1
150    }
151
152    /// Place and remove operations for the solver, updated to use the new structs.
153    fn place_number(&mut self, r: usize, c: usize, num: u8) {
154        self.board.set(r, c, num);
155        self.masks.add_number(r, c, num);
156        self.candidates
157            .update_affected_cells_for(r, c, &self.masks, &self.board, Some(num));
158    }
159
160    /// Remove a number from the board and update masks and candidates.
161    fn remove_number(&mut self, r: usize, c: usize, num: u8) {
162        self.board.set(r, c, 0); // Set back to empty
163        self.masks.remove_number(r, c, num);
164        self.candidates
165            .update_affected_cells(r, c, &self.masks, &self.board);
166        // Note: `update_affected_cells` will recalculate candidates for the removed cell.
167    }
168
169    /// Recursive function to solve the Sudoku puzzle with backtracking.
170    fn solve_until_recursive(
171        &mut self,
172        solutions: &mut Vec<Solution>,
173        path: &mut SolvePath,
174        bound: usize,
175    ) -> usize {
176        // Early return for base case: no empty cells means puzzle is solved
177        let Some((r, c)) = self.find_next_empty_cell() else {
178            solutions.push(Solution {
179                board: self.board,
180                solve_path: path.clone(),
181            });
182            return 1;
183        };
184
185        let mut count = 0;
186        // Use the candidate cache to only iterate valid candidates
187        let mask = self.candidates.get(r, c);
188        let mut nums = Self::candidates_from_mask(mask);
189        nums.shuffle(&mut rng());
190
191        for &num in &nums {
192            if !self.masks.is_safe(r, c, num) {
193                continue;
194            }
195
196            self.place_number(r, c, num);
197            let step_number = path.steps.len() as u32;
198            path.steps.push(SolveStep::Placement {
199                row: r,
200                col: c,
201                value: num,
202                flags: TechniqueFlags::empty(),
203                step_number,
204                candidates_eliminated: 0,
205                related_cell_count: 0,
206                difficulty_point: 0,
207            });
208
209            count += self.solve_until_recursive(solutions, path, bound);
210            path.steps.pop();
211            self.remove_number(r, c, num);
212
213            // Early return if we've found enough solutions
214            if bound > 0 && solutions.len() >= bound {
215                return count;
216            }
217        }
218
219        count
220    }
221
222    /// Run techniques and check if they make valid changes.
223    fn techniques_make_valid_changes(&mut self, path: &mut SolvePath) -> bool {
224        let mut propagator = TechniquePropagator::new(
225            &mut self.board,
226            &mut self.masks,
227            &mut self.candidates,
228            self.techniques,
229        );
230        propagator.propagate_constraints(path, 0)
231    }
232
233    /// Solves the Sudoku puzzle up to a certain bound, returning solutions with their solve paths.
234    pub fn solve_until(&mut self, bound: usize) -> Vec<Solution> {
235        let mut solutions = Vec::new();
236        let mut path = SolvePath::default();
237
238        if !self.techniques_make_valid_changes(&mut path) {
239            return solutions;
240        }
241
242        self.solve_until_recursive(&mut solutions, &mut path, bound);
243        solutions
244    }
245
246    /// Attempts to solve the Sudoku puzzle using backtracking with MRV (Minimum Remaining Values).
247    pub fn solve_any(&mut self) -> Option<Solution> {
248        self.solve_until(1).into_iter().next()
249    }
250
251    /// Finds all possible solutions for the Sudoku puzzle.
252    pub fn solve_all(&mut self) -> Vec<Solution> {
253        use rayon::prelude::*;
254
255        // Run technique propagation once on the current solver state.
256        let mut path = SolvePath::default();
257        if !self.techniques_make_valid_changes(&mut path) {
258            return Vec::new();
259        }
260
261        // If there is at least one empty cell, split work by the first MRV cell's candidates.
262        if let Some((r, c)) = self.find_next_empty_cell() {
263            let mask = self.candidates.get(r, c);
264            let nums = Self::candidates_from_mask(mask);
265
266            let initial_path = path.clone();
267
268            // Parallelize each top-level candidate branch.
269            let chunks: Vec<Vec<Solution>> = nums
270                .par_iter()
271                .map(|&num| {
272                    let mut cloned = *self; // Rustoku is Copy/Clone
273                    let mut local_solutions: Vec<Solution> = Vec::new();
274                    let mut local_path = initial_path.clone();
275
276                    // Place the candidate and record the placement in the path.
277                    cloned.place_number(r, c, num);
278                    let step_number = local_path.steps.len() as u32;
279                    local_path.steps.push(SolveStep::Placement {
280                        row: r,
281                        col: c,
282                        value: num,
283                        flags: TechniqueFlags::empty(),
284                        step_number,
285                        candidates_eliminated: 0,
286                        related_cell_count: 0,
287                        difficulty_point: 0,
288                    });
289
290                    // Continue DFS from this state without re-running the propagator.
291                    cloned.solve_until_recursive(&mut local_solutions, &mut local_path, 0);
292                    local_solutions
293                })
294                .collect();
295
296            // Flatten results
297            let mut solutions = Vec::new();
298            for mut s in chunks {
299                solutions.append(&mut s);
300            }
301            solutions
302        } else {
303            // Already solved after propagation
304            vec![Solution {
305                board: self.board,
306                solve_path: path,
307            }]
308        }
309    }
310
311    /// Checks if the Sudoku puzzle is solved correctly.
312    pub fn is_solved(&self) -> bool {
313        self.board.cells.iter().flatten().all(|&val| val != 0) && Rustoku::new(self.board).is_ok()
314    }
315}
316
317/// A simple builder for constructing `Rustoku` with fluent configuration.
318pub struct RustokuBuilder {
319    board: Option<Board>,
320    techniques: TechniqueFlags,
321    max_solutions: Option<usize>,
322}
323
324impl RustokuBuilder {
325    /// Create a new builder with reasonable defaults.
326    pub fn new() -> Self {
327        RustokuBuilder {
328            board: None,
329            techniques: TechniqueFlags::EASY,
330            max_solutions: None,
331        }
332    }
333}
334
335impl Default for RustokuBuilder {
336    fn default() -> Self {
337        Self::new()
338    }
339}
340
341impl RustokuBuilder {
342    /// Provide the initial `Board` for the solver.
343    pub fn board(mut self, board: Board) -> Self {
344        self.board = Some(board);
345        self
346    }
347
348    /// Provide the initial board as a string (convenience).
349    pub fn board_from_str(mut self, s: &str) -> Result<Self, RustokuError> {
350        let board = Board::try_from(s)?;
351        self.board = Some(board);
352        Ok(self)
353    }
354
355    /// Configure which techniques the solver should use.
356    pub fn techniques(mut self, techniques: TechniqueFlags) -> Self {
357        self.techniques = techniques;
358        self
359    }
360
361    /// Optionally hint the builder with a maximum number of solutions.
362    pub fn max_solutions(mut self, max: usize) -> Self {
363        self.max_solutions = Some(max);
364        self
365    }
366
367    /// Finalize the builder and construct the `Rustoku` instance.
368    pub fn build(self) -> Result<Rustoku, RustokuError> {
369        let board = self.board.unwrap_or_default();
370        let mut r = Rustoku::new(board)?;
371        r.techniques = self.techniques;
372        // If the user provided a max_solutions hint, we store it in techniques as not applicable
373        // for now; the builder primarily configures creation state.
374        Ok(r)
375    }
376}
377
378/// Lazy iterator wrapper for solutions. Uses an explicit DFS stack and yields
379/// solutions one-by-one without computing them all up-front.
380#[derive(Debug)]
381pub struct Solutions {
382    solver: Rustoku,
383    path: SolvePath,
384    stack: Vec<Frame>,
385    finished: bool,
386}
387
388#[derive(Debug)]
389struct Frame {
390    r: usize,
391    c: usize,
392    nums: Vec<u8>,
393    idx: usize,
394    placed: Option<u8>,
395}
396
397impl Solutions {
398    /// Construct a `Solutions` iterator from an existing `Rustoku` solver.
399    /// This will run the technique propagator once before starting DFS.
400    pub fn from_solver(mut solver: Rustoku) -> Self {
401        let mut path = SolvePath::default();
402        let mut finished = false;
403
404        if !solver.techniques_make_valid_changes(&mut path) {
405            finished = true;
406        }
407
408        let mut stack = Vec::new();
409        if !finished {
410            if let Some((r, c)) = solver.find_next_empty_cell() {
411                let mask = solver.candidates.get(r, c);
412                let mut nums = Rustoku::candidates_from_mask(mask);
413                nums.shuffle(&mut rng());
414                stack.push(Frame {
415                    r,
416                    c,
417                    nums,
418                    idx: 0,
419                    placed: None,
420                });
421            } else {
422                // Already solved; leave stack empty and let next() yield the board once
423            }
424        }
425
426        Solutions {
427            solver,
428            path,
429            stack,
430            finished,
431        }
432    }
433}
434
435impl Iterator for Solutions {
436    type Item = Solution;
437
438    fn next(&mut self) -> Option<Self::Item> {
439        if self.finished {
440            return None;
441        }
442
443        loop {
444            // If stack is empty, check if there are any empty cells left
445            if self.stack.is_empty() {
446                if let Some((r, c)) = self.solver.find_next_empty_cell() {
447                    let mask = self.solver.candidates.get(r, c);
448                    let mut nums = Rustoku::candidates_from_mask(mask);
449                    nums.shuffle(&mut rng());
450                    self.stack.push(Frame {
451                        r,
452                        c,
453                        nums,
454                        idx: 0,
455                        placed: None,
456                    });
457                    continue;
458                } else {
459                    // No empty cells -> current board is a solution
460                    let sol = Solution {
461                        board: self.solver.board,
462                        solve_path: self.path.clone(),
463                    };
464                    self.finished = true;
465                    return Some(sol);
466                }
467            }
468
469            let last_idx = self.stack.len() - 1;
470            let frame = &mut self.stack[last_idx];
471
472            // If we've exhausted candidates for this frame
473            if frame.idx >= frame.nums.len() {
474                if let Some(num) = frame.placed {
475                    // remove the previously placed number
476                    self.solver.remove_number(frame.r, frame.c, num);
477                    self.path.steps.pop();
478                    frame.placed = None;
479                } else {
480                    // No placement was made for this frame; pop it and continue
481                    self.stack.pop();
482                }
483                continue;
484            }
485
486            let num = frame.nums[frame.idx];
487            frame.idx += 1;
488
489            if self.solver.masks.is_safe(frame.r, frame.c, num) {
490                // place and record
491                self.solver.place_number(frame.r, frame.c, num);
492                let step_number = self.path.steps.len() as u32;
493                self.path.steps.push(SolveStep::Placement {
494                    row: frame.r,
495                    col: frame.c,
496                    value: num,
497                    flags: TechniqueFlags::empty(),
498                    step_number,
499                    candidates_eliminated: 0,
500                    related_cell_count: 0,
501                    difficulty_point: 0,
502                });
503                frame.placed = Some(num);
504
505                // Find next empty cell after this placement
506                if let Some((nr, nc)) = self.solver.find_next_empty_cell() {
507                    let mask = self.solver.candidates.get(nr, nc);
508                    let mut nums2 = Rustoku::candidates_from_mask(mask);
509                    nums2.shuffle(&mut rng());
510                    self.stack.push(Frame {
511                        r: nr,
512                        c: nc,
513                        nums: nums2,
514                        idx: 0,
515                        placed: None,
516                    });
517                    continue;
518                } else {
519                    // Found a solution. Capture it, then backtrack one placement so iteration can continue.
520                    let solution = Solution {
521                        board: self.solver.board,
522                        solve_path: self.path.clone(),
523                    };
524                    // Backtrack the placement we just made on this frame
525                    if let Some(pnum) = frame.placed {
526                        self.solver.remove_number(frame.r, frame.c, pnum);
527                        self.path.steps.pop();
528                        frame.placed = None;
529                    }
530                    return Some(solution);
531                }
532            }
533            // else try next candidate
534        }
535    }
536}
537
538/// Generates a new Sudoku puzzle with a unique solution.
539///
540/// The `num_clues` parameter specifies the desired number of initially
541/// filled cells (clues) in the generated puzzle. Fewer clues generally
542/// result in a harder puzzle. The actual number of clues may be slightly
543/// more than `num_clues` if it's impossible to remove more numbers
544/// while maintaining a unique solution.
545///
546/// # Example
547///
548/// Generate a puzzle with 30 clues:
549/// ```
550/// use rustoku_lib::generate_board;
551/// let puzzle = generate_board(30);
552/// assert!(puzzle.is_ok());
553/// ```
554pub fn generate_board(num_clues: usize) -> Result<Board, RustokuError> {
555    if !(17..=81).contains(&num_clues) {
556        return Err(RustokuError::InvalidClueCount);
557    }
558
559    // Start with a fully solved board
560    let mut rustoku = Rustoku::new(Board::default())?;
561    let solution = rustoku.solve_any().ok_or(RustokuError::DuplicateValues)?;
562    let mut board = solution.board;
563
564    // Shuffle all cell coordinates
565    let mut cells: Vec<(usize, usize)> = board.iter_cells().collect();
566    cells.shuffle(&mut rng());
567
568    let mut clues = 81;
569
570    // Remove numbers while maintaining a unique solution
571    for &(r, c) in &cells {
572        if clues <= num_clues {
573            break;
574        }
575
576        let original = board.cells[r][c];
577        board.cells[r][c] = 0;
578
579        if Rustoku::new(board)?.solve_until(2).len() != 1 {
580            board.cells[r][c] = original; // Restore if not unique
581        } else {
582            clues -= 1;
583        }
584    }
585
586    // Check if the generated puzzle has a unique solution
587    if Rustoku::new(board)?.solve_until(2).len() != 1 {
588        // If not unique, return an error
589        return Err(RustokuError::GenerateFailure);
590    }
591
592    Ok(board)
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598    use crate::core::board::Board;
599    use crate::error::RustokuError;
600    use crate::format::format_line;
601
602    const UNIQUE_PUZZLE: &str =
603        "530070000600195000098000060800060003400803001700020006060000280000419005000080079";
604    const UNIQUE_SOLUTION: &str =
605        "534678912672195348198342567859761423426853791713924856961537284287419635345286179";
606    const TWO_PUZZLE: &str =
607        "295743861431865900876192543387459216612387495549216738763504189928671354154938600";
608    const SIX_PUZZLE: &str =
609        "295743001431865900876192543387459216612387495549216738763500000000000000000000000";
610
611    #[test]
612    fn test_new_with_bytes_and_str() {
613        let board = [
614            [5, 3, 0, 0, 7, 0, 0, 0, 0],
615            [6, 0, 0, 1, 9, 5, 0, 0, 0],
616            [0, 9, 8, 0, 0, 0, 0, 6, 0],
617            [8, 0, 0, 0, 6, 0, 0, 0, 3],
618            [4, 0, 0, 8, 0, 3, 0, 0, 1],
619            [7, 0, 0, 0, 2, 0, 0, 0, 6],
620            [0, 6, 0, 0, 0, 0, 2, 8, 0],
621            [0, 0, 0, 4, 1, 9, 0, 0, 5],
622            [0, 0, 0, 0, 8, 0, 0, 7, 9],
623        ];
624
625        let flat_bytes: [u8; 81] = board
626            .concat()
627            .try_into()
628            .expect("Concat board to bytes failed");
629        let board_str: String = flat_bytes.iter().map(|&b| (b + b'0') as char).collect();
630
631        let board_from_new = Board::new(board);
632        let board_from_bytes = Board::try_from(flat_bytes).expect("Board from flat bytes failed");
633        let board_from_str = Board::try_from(board_str.as_str()).expect("Board from string failed");
634
635        assert_eq!(board_from_new, board_from_bytes);
636        assert_eq!(board_from_new, board_from_str);
637        assert_eq!(board_from_bytes, board_from_str);
638    }
639
640    #[test]
641    fn test_try_from_with_valid_input() {
642        let rustoku = Board::try_from(UNIQUE_PUZZLE);
643        assert!(rustoku.is_ok());
644    }
645
646    #[test]
647    fn test_try_from_with_invalid_length() {
648        let s = "530070000"; // Too short
649        let rustoku = Board::try_from(s);
650        assert!(matches!(rustoku, Err(RustokuError::InvalidInputLength)));
651    }
652
653    #[test]
654    fn test_try_from_with_invalid_character() {
655        let s = "53007000060019500009800006080006000340080300170002000606000028000041900500008007X"; // 'X'
656        let rustoku = Board::try_from(s);
657        assert!(matches!(rustoku, Err(RustokuError::InvalidInputCharacter)));
658    }
659
660    #[test]
661    fn test_builder_and_iterator() {
662        let board = Board::try_from(UNIQUE_PUZZLE).expect("valid puzzle");
663        let solver = Rustoku::builder()
664            .board(board)
665            .techniques(TechniqueFlags::all())
666            .build()
667            .expect("builder build");
668
669        // Using the iterator wrapper (eager compute, lazy yield)
670        let mut sols = Solutions::from_solver(solver);
671        let first = sols.next();
672        assert!(first.is_some());
673        // For unique puzzle, there should be exactly one solution
674        assert!(sols.next().is_none());
675    }
676
677    #[test]
678    fn test_try_from_with_duplicate_initial_values() {
679        let s = "530070000600195000098000060800060003400803001700020006060000280000419005500080079";
680        let board = Board::try_from(s).expect("Board parsing failed before duplicate check");
681        let rustoku = Rustoku::new(board);
682        assert!(matches!(rustoku, Err(RustokuError::DuplicateValues)));
683    }
684
685    #[test]
686    fn test_solve_any_with_solvable_sudoku() {
687        let s = UNIQUE_PUZZLE;
688        let mut rustoku =
689            Rustoku::new_from_str(s).expect("Rustoku creation failed from puzzle string");
690        let solution = rustoku.solve_any().expect("Solving solvable puzzle failed");
691
692        assert_eq!(
693            UNIQUE_SOLUTION,
694            format_line(&solution.board),
695            "Solution does not match the expected result"
696        );
697    }
698
699    #[test]
700    fn test_solve_any_with_unsolvable_sudoku() {
701        let s = "078002609030008020002000083000000040043090000007300090200001036001840902050003007";
702        let mut rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed");
703        let solution = rustoku.solve_any();
704        assert!(
705            solution.is_none(),
706            "Expected no solution for this unsolvable puzzle"
707        );
708    }
709
710    #[test]
711    fn test_solve_until_with_bound() {
712        let s = UNIQUE_PUZZLE;
713        let mut rustoku =
714            Rustoku::new_from_str(s).expect("Rustoku creation failed from puzzle string");
715
716        let solutions = rustoku.solve_until(1);
717        assert_eq!(
718            1,
719            solutions.len(),
720            "Expected exactly one solution with bound = 1"
721        );
722
723        let all_solutions = rustoku.solve_until(0);
724        assert_eq!(
725            1,
726            all_solutions.len(),
727            "Expected exactly one solution for this board with bound = 0"
728        );
729
730        assert_eq!(
731            solutions[0].board, all_solutions[0].board,
732            "Solution with bound = 1 does not match the solution with bound = 0"
733        );
734    }
735
736    #[test]
737    fn test_solve_all_with_unique_puzzle() {
738        let s = UNIQUE_PUZZLE;
739        let mut rustoku =
740            Rustoku::new_from_str(s).expect("Rustoku creation failed from unique puzzle string");
741        let solutions = rustoku.solve_all();
742        assert_eq!(
743            1,
744            solutions.len(),
745            "Expected a unique solution for the board"
746        );
747    }
748
749    #[test]
750    fn test_solve_all_with_two_puzzle() {
751        let s = TWO_PUZZLE;
752        let mut rustoku =
753            Rustoku::new_from_str(s).expect("Rustoku creation failed from two puzzle string");
754        let solutions = rustoku.solve_all();
755        assert_eq!(
756            2,
757            solutions.len(),
758            "Expected two solutions for the given board"
759        );
760    }
761
762    #[test]
763    fn test_solve_all_with_six_puzzle() {
764        let s = SIX_PUZZLE;
765        let mut rustoku =
766            Rustoku::new_from_str(s).expect("Rustoku creation failed from six puzzle string");
767        let solutions = rustoku.solve_all();
768        assert_eq!(
769            6,
770            solutions.len(),
771            "Expected one solution for the six puzzle"
772        );
773    }
774
775    #[test]
776    fn test_solve_any_with_all_techniques() {
777        let s = UNIQUE_PUZZLE;
778        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for technique test");
779        let solution = rustoku
780            .with_techniques(TechniqueFlags::all())
781            .solve_any()
782            .expect("Solving with all techniques failed");
783
784        assert_eq!(
785            UNIQUE_SOLUTION,
786            format_line(&solution.board),
787            "Solution does not match the expected result with all techniques"
788        );
789    }
790
791    #[test]
792    fn test_solve_all_with_all_techniques() {
793        let s = TWO_PUZZLE;
794        let rustoku = Rustoku::new_from_str(s)
795            .expect("Rustoku creation failed for multi-solution technique test");
796        let solutions = rustoku.with_techniques(TechniqueFlags::all()).solve_all();
797
798        assert_eq!(
799            2,
800            solutions.len(),
801            "Expected two solutions for the given board with all techniques"
802        );
803    }
804
805    #[test]
806    fn test_generate_with_enough_clues() {
807        (20..=80).step_by(20).for_each(|num_clues| {
808            let board = generate_board(num_clues)
809                .unwrap_or_else(|_| panic!("Board generation failed for {num_clues} clues"));
810            let mut rustoku =
811                Rustoku::new(board).expect("Rustoku creation failed from generated board");
812            let clues_count = board
813                .cells
814                .iter()
815                .flatten()
816                .filter(|&&cell| cell != 0)
817                .count();
818            assert!(
819                clues_count >= num_clues,
820                "Expected at least {num_clues} clues, but found {clues_count} clues"
821            );
822
823            let solutions = rustoku.solve_all();
824            assert_eq!(
825                1,
826                solutions.len(),
827                "Generated puzzle with {num_clues} clues should have a unique solution"
828            );
829        })
830    }
831
832    #[test]
833    fn test_generate_with_too_few_clues() {
834        let num_clues = 16;
835        let result = generate_board(num_clues);
836        assert!(matches!(result, Err(RustokuError::InvalidClueCount)));
837    }
838
839    #[test]
840    fn test_generate_with_too_many_clues() {
841        let num_clues = 82;
842        let result = generate_board(num_clues);
843        assert!(matches!(result, Err(RustokuError::InvalidClueCount)));
844    }
845
846    #[test]
847    fn test_is_solved_with_valid_solution() {
848        let s = UNIQUE_SOLUTION;
849        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for solved check");
850        assert!(rustoku.is_solved(), "The Sudoku puzzle should be solved");
851    }
852
853    #[test]
854    fn test_is_solved_with_unsolved_board() {
855        let s = UNIQUE_PUZZLE;
856        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for unsolved check");
857        assert!(!rustoku.is_solved(), "The board should not be valid");
858    }
859
860    struct TechniqueTestCase<'a> {
861        name: &'a str,
862        trigger_string: &'a str,
863        technique_flag: TechniqueFlags,
864    }
865
866    #[test]
867    fn test_each_technique_makes_valid_changes() {
868        let test_cases = vec![
869            // Last digit is empty, no other option exists
870            TechniqueTestCase {
871                name: "Naked Singles",
872                trigger_string: "385421967194756328627983145571892634839645271246137589462579813918364752753218490",
873                technique_flag: TechniqueFlags::NAKED_SINGLES,
874            },
875            // https://hodoku.sourceforge.net/en/show_example.php?file=h101&tech=Hidden+Single
876            TechniqueTestCase {
877                name: "Hidden Singles",
878                trigger_string: "008007000016083000000000051107290000000000000000046307290000000000860140000300700",
879                technique_flag: TechniqueFlags::HIDDEN_SINGLES,
880            },
881            // https://hodoku.sourceforge.net/en/show_example.php?file=n201&tech=Naked+Pair
882            TechniqueTestCase {
883                name: "Naked Pairs",
884                trigger_string: "700009030000105006400260009002083951007000000005600000000000003100000060000004010",
885                technique_flag: TechniqueFlags::NAKED_PAIRS,
886            },
887            // https://hodoku.sourceforge.net/en/show_example.php?file=h201&tech=Hidden+Pair
888            TechniqueTestCase {
889                name: "Hidden Pairs",
890                trigger_string: "000032000000000000007600914096000800005008000030040005050200000700000560904010000",
891                // Needs easy techniques to reduce candidates first, then hidden pairs to find the pair
892                technique_flag: TechniqueFlags::EASY | TechniqueFlags::HIDDEN_PAIRS,
893            },
894            // https://hodoku.sourceforge.net/en/show_example.php?file=lc101&tech=Locked+Candidates+Type+1+%28Pointing%29
895            TechniqueTestCase {
896                name: "Locked Candidates",
897                trigger_string: "984000000000500040000000002006097200003002000000000010005060003407051890030009700",
898                technique_flag: TechniqueFlags::LOCKED_CANDIDATES,
899            },
900            // https://hodoku.sourceforge.net/en/show_example.php?file=bf201&tech=X-Wing
901            TechniqueTestCase {
902                name: "X-Wing",
903                trigger_string: "000000000760003002002640009403900070000004903005000020010560000370090041000000060",
904                technique_flag: TechniqueFlags::XWING,
905            },
906        ];
907
908        for test_case in test_cases {
909            let rustoku = Rustoku::new_from_str(test_case.trigger_string)
910                .unwrap_or_else(|_| panic!("Rustoku creation failed for '{}'", test_case.name));
911            let mut path = SolvePath::default();
912            assert!(
913                rustoku
914                    .with_techniques(test_case.technique_flag)
915                    .techniques_make_valid_changes(&mut path),
916                "Propagation should not contradict for '{}'",
917                test_case.name
918            );
919            assert!(
920                !path.steps.is_empty(),
921                "Expected at least one placement or elimination for '{}'",
922                test_case.name
923            )
924        }
925    }
926
927    // ── Targeted technique tests ──────────────────────────────────────────
928
929    #[test]
930    fn test_naked_singles_places_correct_value() {
931        // Board with a single empty cell at (8,8) – only candidate is 6
932        let s = "385421967194756328627983145571892634839645271246137589462579813918364752753218490";
933        let mut rustoku = Rustoku::new_from_str(s)
934            .unwrap()
935            .with_techniques(TechniqueFlags::NAKED_SINGLES);
936        let mut path = SolvePath::default();
937        rustoku.techniques_make_valid_changes(&mut path);
938
939        // Expect exactly one placement at (8,8) = 6
940        let placements: Vec<_> = path
941            .steps
942            .iter()
943            .filter_map(|step| match step {
944                SolveStep::Placement {
945                    row, col, value, ..
946                } => Some((*row, *col, *value)),
947                _ => None,
948            })
949            .collect();
950
951        assert!(
952            placements.contains(&(8, 8, 6)),
953            "Expected placement of 6 at (8,8), got {:?}",
954            placements
955        );
956    }
957
958    #[test]
959    fn test_hidden_singles_places_in_correct_cell() {
960        // Hodoku hidden single example
961        let s = "008007000016083000000000051107290000000000000000046307290000000000860140000300700";
962        let mut rustoku = Rustoku::new_from_str(s)
963            .unwrap()
964            .with_techniques(TechniqueFlags::HIDDEN_SINGLES);
965        let mut path = SolvePath::default();
966        rustoku.techniques_make_valid_changes(&mut path);
967
968        let placements: Vec<_> = path
969            .steps
970            .iter()
971            .filter_map(|step| match step {
972                SolveStep::Placement {
973                    row,
974                    col,
975                    value,
976                    flags,
977                    ..
978                } if flags.contains(TechniqueFlags::HIDDEN_SINGLES) => Some((*row, *col, *value)),
979                _ => None,
980            })
981            .collect();
982
983        assert!(
984            !placements.is_empty(),
985            "Hidden singles should produce at least one placement"
986        );
987
988        // Verify each placement is valid in the final board
989        for &(r, c, v) in &placements {
990            assert!((1..=9).contains(&v), "Placed value must be 1-9, got {v}");
991            assert_eq!(
992                rustoku.board.get(r, c),
993                v,
994                "Board cell ({r},{c}) should be {v} after hidden single"
995            );
996        }
997    }
998
999    #[test]
1000    fn test_naked_pairs_eliminates_candidates() {
1001        // Hodoku naked pair example
1002        let s = "700009030000105006400260009002083951007000000005600000000000003100000060000004010";
1003        let mut rustoku = Rustoku::new_from_str(s)
1004            .unwrap()
1005            .with_techniques(TechniqueFlags::NAKED_PAIRS);
1006        let mut path = SolvePath::default();
1007        rustoku.techniques_make_valid_changes(&mut path);
1008
1009        let eliminations: Vec<_> = path
1010            .steps
1011            .iter()
1012            .filter_map(|step| match step {
1013                SolveStep::CandidateElimination {
1014                    row,
1015                    col,
1016                    value,
1017                    flags,
1018                    ..
1019                } if flags.contains(TechniqueFlags::NAKED_PAIRS) => Some((*row, *col, *value)),
1020                _ => None,
1021            })
1022            .collect();
1023
1024        assert!(
1025            !eliminations.is_empty(),
1026            "Naked pairs should produce at least one candidate elimination"
1027        );
1028
1029        // Verify eliminated candidates are no longer present
1030        for &(r, c, v) in &eliminations {
1031            let cand_bit = 1u16 << (v - 1);
1032            let remaining = rustoku.candidates.get(r, c);
1033            assert_eq!(
1034                remaining & cand_bit,
1035                0,
1036                "Candidate {v} should be eliminated from ({r},{c})"
1037            );
1038        }
1039    }
1040
1041    #[test]
1042    fn test_hidden_pairs_eliminates_non_pair_candidates() {
1043        // Hodoku hidden pair example – needs EASY techniques to simplify first
1044        let s = "000032000000000000007600914096000800005008000030040005050200000700000560904010000";
1045        let mut rustoku = Rustoku::new_from_str(s)
1046            .unwrap()
1047            .with_techniques(TechniqueFlags::EASY | TechniqueFlags::HIDDEN_PAIRS);
1048        let mut path = SolvePath::default();
1049        rustoku.techniques_make_valid_changes(&mut path);
1050
1051        let eliminations: Vec<_> = path
1052            .steps
1053            .iter()
1054            .filter_map(|step| match step {
1055                SolveStep::CandidateElimination {
1056                    row,
1057                    col,
1058                    value,
1059                    flags,
1060                    ..
1061                } if flags.contains(TechniqueFlags::HIDDEN_PAIRS) => Some((*row, *col, *value)),
1062                _ => None,
1063            })
1064            .collect();
1065
1066        assert!(
1067            !eliminations.is_empty(),
1068            "Hidden pairs should produce at least one candidate elimination"
1069        );
1070
1071        // Verify eliminated candidates are no longer present
1072        for &(r, c, v) in &eliminations {
1073            let cand_bit = 1u16 << (v - 1);
1074            let remaining = rustoku.candidates.get(r, c);
1075            assert_eq!(
1076                remaining & cand_bit,
1077                0,
1078                "Candidate {v} should be eliminated from ({r},{c}) by hidden pair"
1079            );
1080        }
1081    }
1082
1083    #[test]
1084    fn test_locked_candidates_eliminates_outside_box() {
1085        // Hodoku locked candidates (pointing) example
1086        let s = "984000000000500040000000002006097200003002000000000010005060003407051890030009700";
1087        let mut rustoku = Rustoku::new_from_str(s)
1088            .unwrap()
1089            .with_techniques(TechniqueFlags::LOCKED_CANDIDATES);
1090        let mut path = SolvePath::default();
1091        rustoku.techniques_make_valid_changes(&mut path);
1092
1093        let eliminations: Vec<_> = path
1094            .steps
1095            .iter()
1096            .filter_map(|step| match step {
1097                SolveStep::CandidateElimination {
1098                    row,
1099                    col,
1100                    value,
1101                    flags,
1102                    ..
1103                } if flags.contains(TechniqueFlags::LOCKED_CANDIDATES) => {
1104                    Some((*row, *col, *value))
1105                }
1106                _ => None,
1107            })
1108            .collect();
1109
1110        assert!(
1111            !eliminations.is_empty(),
1112            "Locked candidates should produce at least one candidate elimination"
1113        );
1114
1115        for &(r, c, v) in &eliminations {
1116            let cand_bit = 1u16 << (v - 1);
1117            let remaining = rustoku.candidates.get(r, c);
1118            assert_eq!(
1119                remaining & cand_bit,
1120                0,
1121                "Candidate {v} should be eliminated from ({r},{c}) by locked candidates"
1122            );
1123        }
1124    }
1125
1126    #[test]
1127    fn test_xwing_eliminates_from_correct_lines() {
1128        // Hodoku X-Wing example
1129        let s = "000000000760003002002640009403900070000004903005000020010560000370090041000000060";
1130        let mut rustoku = Rustoku::new_from_str(s)
1131            .unwrap()
1132            .with_techniques(TechniqueFlags::XWING);
1133        let mut path = SolvePath::default();
1134        rustoku.techniques_make_valid_changes(&mut path);
1135
1136        let eliminations: Vec<_> = path
1137            .steps
1138            .iter()
1139            .filter_map(|step| match step {
1140                SolveStep::CandidateElimination {
1141                    row,
1142                    col,
1143                    value,
1144                    flags,
1145                    ..
1146                } if flags.contains(TechniqueFlags::XWING) => Some((*row, *col, *value)),
1147                _ => None,
1148            })
1149            .collect();
1150
1151        assert!(
1152            !eliminations.is_empty(),
1153            "X-Wing should produce at least one candidate elimination"
1154        );
1155
1156        for &(r, c, v) in &eliminations {
1157            let cand_bit = 1u16 << (v - 1);
1158            let remaining = rustoku.candidates.get(r, c);
1159            assert_eq!(
1160                remaining & cand_bit,
1161                0,
1162                "Candidate {v} should be eliminated from ({r},{c}) by X-Wing"
1163            );
1164        }
1165    }
1166
1167    #[test]
1168    fn test_all_techniques_produce_valid_solution() {
1169        // Every technique-trigger puzzle must still solve to a valid board
1170        let puzzles = vec![
1171            "385421967194756328627983145571892634839645271246137589462579813918364752753218490",
1172            "008007000016083000000000051107290000000000000000046307290000000000860140000300700",
1173            "700009030000105006400260009002083951007000000005600000000000003100000060000004010",
1174            "000032000000000000007600914096000800005008000030040005050200000700000560904010000",
1175            "984000000000500040000000002006097200003002000000000010005060003407051890030009700",
1176            "000000000760003002002640009403900070000004903005000020010560000370090041000000060",
1177        ];
1178
1179        for puzzle in puzzles {
1180            let mut rustoku = Rustoku::new_from_str(puzzle)
1181                .unwrap()
1182                .with_techniques(TechniqueFlags::all());
1183            let solution = rustoku.solve_any();
1184            assert!(
1185                solution.is_some(),
1186                "Puzzle should be solvable with all techniques: {puzzle}"
1187            );
1188            let solved = solution.unwrap().board;
1189            let check = Rustoku::new(solved).unwrap();
1190            assert!(
1191                check.is_solved(),
1192                "Solution must be valid for puzzle: {puzzle}"
1193            );
1194        }
1195    }
1196
1197    #[test]
1198    fn test_techniques_do_not_alter_given_clues() {
1199        // Verify that constraint propagation never overwrites already-given clues
1200        let puzzles = vec![
1201            (
1202                "Naked Singles",
1203                "385421967194756328627983145571892634839645271246137589462579813918364752753218490",
1204                TechniqueFlags::NAKED_SINGLES,
1205            ),
1206            (
1207                "Hidden Singles",
1208                "008007000016083000000000051107290000000000000000046307290000000000860140000300700",
1209                TechniqueFlags::HIDDEN_SINGLES,
1210            ),
1211            (
1212                "Naked Pairs",
1213                "700009030000105006400260009002083951007000000005600000000000003100000060000004010",
1214                TechniqueFlags::NAKED_PAIRS,
1215            ),
1216        ];
1217
1218        for (name, puzzle, flag) in puzzles {
1219            let original = Board::try_from(puzzle).unwrap();
1220            let mut rustoku = Rustoku::new_from_str(puzzle).unwrap().with_techniques(flag);
1221            let mut path = SolvePath::default();
1222            rustoku.techniques_make_valid_changes(&mut path);
1223
1224            for r in 0..9 {
1225                for c in 0..9 {
1226                    let orig_val = original.get(r, c);
1227                    if orig_val != 0 {
1228                        assert_eq!(
1229                            rustoku.board.get(r, c),
1230                            orig_val,
1231                            "{name}: clue at ({r},{c}) was overwritten"
1232                        );
1233                    }
1234                }
1235            }
1236        }
1237    }
1238}