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_builder_and_iterator() {
613        let board = Board::try_from(UNIQUE_PUZZLE).expect("valid puzzle");
614        let solver = Rustoku::builder()
615            .board(board)
616            .techniques(TechniqueFlags::all())
617            .build()
618            .expect("builder build");
619
620        // Using the iterator wrapper (eager compute, lazy yield)
621        let mut sols = Solutions::from_solver(solver);
622        let first = sols.next();
623        assert!(first.is_some());
624        // For unique puzzle, there should be exactly one solution
625        assert!(sols.next().is_none());
626    }
627
628    #[test]
629    fn test_try_from_with_duplicate_initial_values() {
630        let s = "530070000600195000098000060800060003400803001700020006060000280000419005500080079";
631        let board = Board::try_from(s).expect("Board parsing failed before duplicate check");
632        let rustoku = Rustoku::new(board);
633        assert!(matches!(rustoku, Err(RustokuError::DuplicateValues)));
634    }
635
636    #[test]
637    fn test_solve_any_with_solvable_sudoku() {
638        let s = UNIQUE_PUZZLE;
639        let mut rustoku =
640            Rustoku::new_from_str(s).expect("Rustoku creation failed from puzzle string");
641        let solution = rustoku.solve_any().expect("Solving solvable puzzle failed");
642
643        assert_eq!(
644            UNIQUE_SOLUTION,
645            format_line(&solution.board),
646            "Solution does not match the expected result"
647        );
648    }
649
650    #[test]
651    fn test_solve_any_with_unsolvable_sudoku() {
652        let s = "078002609030008020002000083000000040043090000007300090200001036001840902050003007";
653        let mut rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed");
654        let solution = rustoku.solve_any();
655        assert!(
656            solution.is_none(),
657            "Expected no solution for this unsolvable puzzle"
658        );
659    }
660
661    #[test]
662    fn test_solve_until_with_bound() {
663        let s = UNIQUE_PUZZLE;
664        let mut rustoku =
665            Rustoku::new_from_str(s).expect("Rustoku creation failed from puzzle string");
666
667        let solutions = rustoku.solve_until(1);
668        assert_eq!(
669            1,
670            solutions.len(),
671            "Expected exactly one solution with bound = 1"
672        );
673
674        let all_solutions = rustoku.solve_until(0);
675        assert_eq!(
676            1,
677            all_solutions.len(),
678            "Expected exactly one solution for this board with bound = 0"
679        );
680
681        assert_eq!(
682            solutions[0].board, all_solutions[0].board,
683            "Solution with bound = 1 does not match the solution with bound = 0"
684        );
685    }
686
687    #[test]
688    fn test_solve_all_with_unique_puzzle() {
689        let s = UNIQUE_PUZZLE;
690        let mut rustoku =
691            Rustoku::new_from_str(s).expect("Rustoku creation failed from unique puzzle string");
692        let solutions = rustoku.solve_all();
693        assert_eq!(
694            1,
695            solutions.len(),
696            "Expected a unique solution for the board"
697        );
698    }
699
700    #[test]
701    fn test_solve_all_with_two_puzzle() {
702        let s = TWO_PUZZLE;
703        let mut rustoku =
704            Rustoku::new_from_str(s).expect("Rustoku creation failed from two puzzle string");
705        let solutions = rustoku.solve_all();
706        assert_eq!(
707            2,
708            solutions.len(),
709            "Expected two solutions for the given board"
710        );
711    }
712
713    #[test]
714    fn test_solve_all_with_six_puzzle() {
715        let s = SIX_PUZZLE;
716        let mut rustoku =
717            Rustoku::new_from_str(s).expect("Rustoku creation failed from six puzzle string");
718        let solutions = rustoku.solve_all();
719        assert_eq!(
720            6,
721            solutions.len(),
722            "Expected one solution for the six puzzle"
723        );
724    }
725
726    #[test]
727    fn test_solve_any_with_all_techniques() {
728        let s = UNIQUE_PUZZLE;
729        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for technique test");
730        let solution = rustoku
731            .with_techniques(TechniqueFlags::all())
732            .solve_any()
733            .expect("Solving with all techniques failed");
734
735        assert_eq!(
736            UNIQUE_SOLUTION,
737            format_line(&solution.board),
738            "Solution does not match the expected result with all techniques"
739        );
740    }
741
742    #[test]
743    fn test_solve_all_with_all_techniques() {
744        let s = TWO_PUZZLE;
745        let rustoku = Rustoku::new_from_str(s)
746            .expect("Rustoku creation failed for multi-solution technique test");
747        let solutions = rustoku.with_techniques(TechniqueFlags::all()).solve_all();
748
749        assert_eq!(
750            2,
751            solutions.len(),
752            "Expected two solutions for the given board with all techniques"
753        );
754    }
755
756    #[test]
757    fn test_generate_with_enough_clues() {
758        (20..=80).step_by(20).for_each(|num_clues| {
759            let board = generate_board(num_clues)
760                .unwrap_or_else(|_| panic!("Board generation failed for {num_clues} clues"));
761            let mut rustoku =
762                Rustoku::new(board).expect("Rustoku creation failed from generated board");
763            let clues_count = board
764                .cells
765                .iter()
766                .flatten()
767                .filter(|&&cell| cell != 0)
768                .count();
769            assert!(
770                clues_count >= num_clues,
771                "Expected at least {num_clues} clues, but found {clues_count} clues"
772            );
773
774            let solutions = rustoku.solve_all();
775            assert_eq!(
776                1,
777                solutions.len(),
778                "Generated puzzle with {num_clues} clues should have a unique solution"
779            );
780        })
781    }
782
783    #[test]
784    fn test_generate_with_too_few_clues() {
785        let num_clues = 16;
786        let result = generate_board(num_clues);
787        assert!(matches!(result, Err(RustokuError::InvalidClueCount)));
788    }
789
790    #[test]
791    fn test_generate_with_too_many_clues() {
792        let num_clues = 82;
793        let result = generate_board(num_clues);
794        assert!(matches!(result, Err(RustokuError::InvalidClueCount)));
795    }
796
797    #[test]
798    fn test_is_solved_with_valid_solution() {
799        let s = UNIQUE_SOLUTION;
800        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for solved check");
801        assert!(rustoku.is_solved(), "The Sudoku puzzle should be solved");
802    }
803
804    #[test]
805    fn test_is_solved_with_unsolved_board() {
806        let s = UNIQUE_PUZZLE;
807        let rustoku = Rustoku::new_from_str(s).expect("Rustoku creation failed for unsolved check");
808        assert!(!rustoku.is_solved(), "The board should not be valid");
809    }
810}