Skip to main content

rustoku_lib/
format.rs

1//! Formatting module for Rustoku data structures.
2//!
3//! This module provides functions to format the Sudoku board and its solve path
4//! in a way that is suitable for terminals.
5
6use crate::core::{Board, Solution, SolvePath, SolveStep, TechniqueFlags};
7use std::fmt;
8
9/// Formats the solution into a human-readable string representation.
10impl fmt::Display for Solution {
11    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
12        writeln!(f, "{}", self.board)?;
13        write!(f, "\n{}", self.solve_path)?;
14        Ok(())
15    }
16}
17
18/// Formats the board into a human-readable string representation.
19impl fmt::Display for Board {
20    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
21        writeln!(f, "{}", format_grid(self).join("\n"))?;
22        write!(f, "Line format: {}", format_line(self))?;
23        Ok(())
24    }
25}
26
27/// Formats the technique mask into a human-readable string representation.
28impl fmt::Display for TechniqueFlags {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        if self.is_empty() {
31            return write!(f, "None");
32        }
33        if self.is_all() {
34            return write!(f, "All Techniques");
35        }
36
37        let mut techniques = Vec::new();
38
39        if self.contains(TechniqueFlags::NAKED_SINGLES) {
40            techniques.push("Naked Singles");
41        }
42        if self.contains(TechniqueFlags::HIDDEN_SINGLES) {
43            techniques.push("Hidden Singles");
44        }
45        if self.contains(TechniqueFlags::NAKED_PAIRS) {
46            techniques.push("Naked Pairs");
47        }
48        if self.contains(TechniqueFlags::HIDDEN_PAIRS) {
49            techniques.push("Hidden Pairs");
50        }
51        if self.contains(TechniqueFlags::LOCKED_CANDIDATES) {
52            techniques.push("Locked Candidates");
53        }
54        if self.contains(TechniqueFlags::X_WING) {
55            techniques.push("X-Wing");
56        }
57        if self.contains(TechniqueFlags::SWORDFISH) {
58            techniques.push("Swordfish");
59        }
60        if self.contains(TechniqueFlags::XY_WING) {
61            techniques.push("XY-Wing");
62        }
63        if self.contains(TechniqueFlags::XYZ_WING) {
64            techniques.push("XYZ-Wing");
65        }
66        if self.contains(TechniqueFlags::W_WING) {
67            techniques.push("W-Wing");
68        }
69        if self.contains(TechniqueFlags::NAKED_TRIPLES) {
70            techniques.push("Naked Triples");
71        }
72        if self.contains(TechniqueFlags::HIDDEN_TRIPLES) {
73            techniques.push("Hidden Triples");
74        }
75
76        write!(f, "{}", techniques.join(", "))
77    }
78}
79
80/// Formats the solve path into a human-readable string representation.
81impl fmt::Display for SolvePath {
82    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
83        let formatted_lines = format_solve_path(self, 5);
84        write!(f, "{}", formatted_lines.join("\n"))
85    }
86}
87
88/// Formats the solve step into a human-readable string representation.
89impl fmt::Display for SolveStep {
90    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91        match self {
92            SolveStep::Placement {
93                row,
94                col,
95                value,
96                flags,
97                step_number,
98                candidates_eliminated,
99                related_cell_count,
100                difficulty_point,
101            } => {
102                write!(
103                    f,
104                    "#{:3} | Value {value} is placed on R{row}C{col} by {flags} | elim:{} related:{} diff:{}",
105                    step_number + 1,
106                    bin(*candidates_eliminated).count_ones(),
107                    related_cell_count,
108                    difficulty_point
109                )
110            }
111            SolveStep::CandidateElimination {
112                row,
113                col,
114                value,
115                flags,
116                step_number,
117                candidates_eliminated,
118                related_cell_count,
119                difficulty_point,
120            } => {
121                write!(
122                    f,
123                    "#{:3} | Value {value} is eliminated from R{row}C{col} by {flags} | elim:{} related:{} diff:{}",
124                    step_number + 1,
125                    bin(*candidates_eliminated).count_ones() + 1, // +1 for the main elimination
126                    related_cell_count,
127                    difficulty_point
128                )
129            }
130        }
131    }
132}
133
134/// Formats a u32 bitmask showing binary representation (helper for diagnostics).
135fn bin(x: u32) -> u32 {
136    x
137}
138
139/// Formats the Sudoku board into a grid representation.
140///
141/// This function takes a 9x9 Sudoku board and formats it into a grid with
142/// horizontal and vertical separators to visually distinguish the 3x3 boxes.
143/// Each cell is represented by its number, with empty cells shown as a dot (`.`).
144pub(crate) fn format_grid(board: &Board) -> Vec<String> {
145    let mut grid = Vec::new();
146    let horizontal_line = "+-------+-------+-------+";
147
148    grid.push(horizontal_line.to_string()); // Top line
149
150    for (r, row) in board.cells.iter().enumerate().take(9) {
151        let mut line = String::from("|"); // Start of the row
152        for (c, &cell) in row.iter().enumerate().take(9) {
153            match cell {
154                0 => line.push_str(" ."), // Empty cell, two spaces for alignment
155                n => line.push_str(&format!(" {n}")), // Number, two spaces for alignment
156            }
157            if (c + 1) % 3 == 0 {
158                line.push_str(" |"); // Vertical separator after every 3rd column
159            }
160        }
161        grid.push(line); // Add the row to the grid
162
163        if (r + 1) % 3 == 0 {
164            grid.push(horizontal_line.to_string()); // Horizontal separator after every 3rd row
165        }
166    }
167
168    grid
169}
170
171/// Formats the Sudoku board into a single line string representation.
172///
173/// This function converts the board into a single string where each number is
174/// represented by its digit, and empty cells are represented by a dot (`.`).
175pub(crate) fn format_line(board: &Board) -> String {
176    board
177        .cells
178        .iter()
179        .flatten()
180        .map(|&n| (n + b'0') as char)
181        .collect()
182}
183
184/// Formats a path of moves in the Sudoku solving process into a vector of strings.
185///
186/// This function takes a `SolvePath` struct and formats its moves into a compact multi-step format.
187/// Each line shows exactly 3 steps with diagnostic metadata for efficient overview.
188pub(crate) fn format_solve_path(solve_path: &SolvePath, _chunk_size: usize) -> Vec<String> {
189    if solve_path.steps.is_empty() {
190        return vec!["(No moves recorded)".to_string()];
191    }
192
193    let mut result = Vec::new();
194    let mut current_technique = None;
195    let mut current_moves = Vec::new();
196
197    for step in &solve_path.steps {
198        let flags = match step {
199            SolveStep::Placement { flags, .. } | SolveStep::CandidateElimination { flags, .. } => {
200                *flags
201            }
202        };
203
204        let technique_name = format!("{flags}");
205
206        if current_technique.as_ref() != Some(&technique_name) {
207            // Flush previous technique's moves
208            if let Some(tech) = current_technique {
209                result.push(format!("{tech}:"));
210                // Use 1 step per line for maximum clarity and learning
211                for chunk in current_moves.chunks(1) {
212                    // Format with padding: each step gets 5 chars width for neat alignment
213                    let formatted_chunk: Vec<String> =
214                        chunk.iter().map(|s| format!("{:<5}", s)).collect();
215                    result.push(format!("  {}", formatted_chunk.join("")));
216                }
217                current_moves.clear();
218            }
219            current_technique = Some(technique_name);
220        }
221
222        // Format as compact step with readable labels
223        let step_str = match step {
224            SolveStep::Placement {
225                row,
226                col,
227                value,
228                step_number,
229                candidates_eliminated,
230                related_cell_count,
231                difficulty_point,
232                ..
233            } => {
234                format!(
235                    "#{} R{}C{}={} [E:{} R:{} D:{}]",
236                    step_number + 1,
237                    row + 1,
238                    col + 1,
239                    value,
240                    candidates_eliminated,
241                    related_cell_count,
242                    difficulty_point
243                )
244            }
245            SolveStep::CandidateElimination {
246                row,
247                col,
248                value,
249                step_number,
250                candidates_eliminated,
251                related_cell_count,
252                difficulty_point,
253                ..
254            } => {
255                let total_elim = *candidates_eliminated + 1;
256                format!(
257                    "#{} -{}@R{}C{} [E:{} R:{} D:{}]",
258                    step_number + 1,
259                    value,
260                    row + 1,
261                    col + 1,
262                    total_elim,
263                    related_cell_count,
264                    difficulty_point
265                )
266            }
267        };
268
269        current_moves.push(step_str);
270    }
271
272    // Flush final technique
273    if let Some(tech) = current_technique {
274        result.push(format!("{tech}:"));
275        for chunk in current_moves.chunks(1) {
276            let formatted_chunk: Vec<String> = chunk.iter().map(|s| format!("{:<5}", s)).collect();
277            result.push(format!("  {}", formatted_chunk.join("")));
278        }
279    }
280
281    result
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::core::{SolvePath, SolveStep, TechniqueFlags};
288
289    #[test]
290    fn test_format_grid() {
291        let board = Board::new([
292            [5, 3, 0, 6, 7, 8, 9, 1, 2],
293            [6, 7, 2, 1, 9, 5, 3, 4, 8],
294            [1, 9, 8, 3, 4, 2, 5, 6, 7],
295            [8, 5, 9, 7, 6, 1, 4, 2, 3],
296            [4, 2, 6, 8, 5, 3, 7, 9, 1],
297            [7, 1, 3, 9, 2, 4, 8, 5, 6],
298            [9, 6, 1, 5, 3, 7, 2, 8, 4],
299            [2, 8, 7, 4, 1, 9, 6, 3, 5],
300            [3, 4, 5, 2, 8, 6, 1, 7, 9],
301        ]);
302
303        let expected = vec![
304            "+-------+-------+-------+",
305            "| 5 3 . | 6 7 8 | 9 1 2 |",
306            "| 6 7 2 | 1 9 5 | 3 4 8 |",
307            "| 1 9 8 | 3 4 2 | 5 6 7 |",
308            "+-------+-------+-------+",
309            "| 8 5 9 | 7 6 1 | 4 2 3 |",
310            "| 4 2 6 | 8 5 3 | 7 9 1 |",
311            "| 7 1 3 | 9 2 4 | 8 5 6 |",
312            "+-------+-------+-------+",
313            "| 9 6 1 | 5 3 7 | 2 8 4 |",
314            "| 2 8 7 | 4 1 9 | 6 3 5 |",
315            "| 3 4 5 | 2 8 6 | 1 7 9 |",
316            "+-------+-------+-------+",
317        ];
318
319        assert_eq!(expected, format_grid(&board));
320    }
321
322    #[test]
323    fn test_format_line() {
324        let board = Board::new([
325            [5, 3, 0, 6, 7, 8, 9, 1, 2],
326            [6, 7, 2, 1, 9, 5, 3, 4, 8],
327            [1, 9, 8, 3, 4, 2, 5, 6, 7],
328            [8, 5, 9, 7, 6, 1, 4, 2, 3],
329            [4, 2, 6, 8, 5, 3, 7, 9, 1],
330            [7, 1, 3, 9, 2, 4, 8, 5, 6],
331            [9, 6, 1, 5, 3, 7, 2, 8, 4],
332            [2, 8, 7, 4, 1, 9, 6, 3, 5],
333            [3, 4, 5, 2, 8, 6, 1, 7, 9],
334        ]);
335
336        let expected =
337            "530678912672195348198342567859761423426853791713924856961537284287419635345286179";
338        assert_eq!(expected, format_line(&board));
339    }
340
341    #[test]
342    fn test_format_grid_empty_board() {
343        let board = Board::default();
344
345        let expected = vec![
346            "+-------+-------+-------+",
347            "| . . . | . . . | . . . |",
348            "| . . . | . . . | . . . |",
349            "| . . . | . . . | . . . |",
350            "+-------+-------+-------+",
351            "| . . . | . . . | . . . |",
352            "| . . . | . . . | . . . |",
353            "| . . . | . . . | . . . |",
354            "+-------+-------+-------+",
355            "| . . . | . . . | . . . |",
356            "| . . . | . . . | . . . |",
357            "| . . . | . . . | . . . |",
358            "+-------+-------+-------+",
359        ];
360
361        assert_eq!(expected, format_grid(&board));
362    }
363
364    #[test]
365    fn test_format_line_empty_board() {
366        let board = Board::default();
367        let expected =
368            "000000000000000000000000000000000000000000000000000000000000000000000000000000000";
369        assert_eq!(expected, format_line(&board));
370    }
371
372    #[test]
373    fn test_display_empty_mask() {
374        let mask = TechniqueFlags::empty();
375        assert_eq!(format!("{mask}"), "None");
376    }
377
378    #[test]
379    fn test_display_single_technique() {
380        let mask = TechniqueFlags::NAKED_SINGLES;
381        assert_eq!(format!("{mask}"), "Naked Singles");
382
383        let mask = TechniqueFlags::X_WING;
384        assert_eq!(format!("{mask}"), "X-Wing");
385    }
386
387    #[test]
388    fn test_display_multiple_techniques() {
389        let mask = TechniqueFlags::EASY;
390        assert_eq!(format!("{mask}"), "Naked Singles, Hidden Singles");
391
392        let mask = TechniqueFlags::NAKED_SINGLES
393            | TechniqueFlags::X_WING
394            | TechniqueFlags::LOCKED_CANDIDATES;
395        assert_eq!(
396            format!("{mask}"),
397            "Naked Singles, Locked Candidates, X-Wing"
398        );
399    }
400
401    #[test]
402    fn test_empty_path() {
403        let solve_path = SolvePath { steps: Vec::new() }; // Create an empty SolvePath
404        let expected = vec!["(No moves recorded)"];
405        assert_eq!(format_solve_path(&solve_path, 5), expected);
406    }
407
408    #[test]
409    fn test_single_technique_multiple_moves_with_chunking() {
410        let steps = vec![
411            SolveStep::Placement {
412                row: 0,
413                col: 0,
414                value: 1,
415                flags: TechniqueFlags::NAKED_SINGLES,
416                step_number: 0,
417                candidates_eliminated: 9,
418                related_cell_count: 6,
419                difficulty_point: 1,
420            },
421            SolveStep::Placement {
422                row: 0,
423                col: 1,
424                value: 2,
425                flags: TechniqueFlags::NAKED_SINGLES,
426                step_number: 1,
427                candidates_eliminated: 8,
428                related_cell_count: 6,
429                difficulty_point: 1,
430            },
431            SolveStep::Placement {
432                row: 0,
433                col: 2,
434                value: 3,
435                flags: TechniqueFlags::NAKED_SINGLES,
436                step_number: 2,
437                candidates_eliminated: 7,
438                related_cell_count: 6,
439                difficulty_point: 1,
440            },
441            SolveStep::Placement {
442                row: 0,
443                col: 3,
444                value: 4,
445                flags: TechniqueFlags::NAKED_SINGLES,
446                step_number: 3,
447                candidates_eliminated: 6,
448                related_cell_count: 6,
449                difficulty_point: 1,
450            },
451        ];
452        let solve_path = SolvePath { steps };
453
454        let formatted = format_solve_path(&solve_path, 3);
455        assert_eq!(formatted[0], "Naked Singles:");
456        // Each step should be on its own line
457        assert!(formatted[1].contains("#1 R1C1=1"));
458        assert!(formatted[2].contains("#2 R1C2=2"));
459        assert!(formatted[3].contains("#3 R1C3=3"));
460        assert!(formatted[4].contains("#4 R1C4=4"));
461    }
462
463    #[test]
464    fn test_multiple_techniques_and_mixed_chunking() {
465        let steps = vec![
466            SolveStep::Placement {
467                row: 0,
468                col: 0,
469                value: 1,
470                flags: TechniqueFlags::NAKED_SINGLES,
471                step_number: 0,
472                candidates_eliminated: 9,
473                related_cell_count: 6,
474                difficulty_point: 1,
475            },
476            SolveStep::Placement {
477                row: 1,
478                col: 0,
479                value: 3,
480                flags: TechniqueFlags::HIDDEN_SINGLES,
481                step_number: 1,
482                candidates_eliminated: 8,
483                related_cell_count: 9,
484                difficulty_point: 2,
485            },
486            SolveStep::CandidateElimination {
487                row: 2,
488                col: 0,
489                value: 6,
490                flags: TechniqueFlags::HIDDEN_PAIRS,
491                step_number: 2,
492                candidates_eliminated: 3,
493                related_cell_count: 4,
494                difficulty_point: 3,
495            },
496        ];
497        let solve_path = SolvePath { steps };
498
499        let formatted = format_solve_path(&solve_path, 3);
500        assert_eq!(formatted[0], "Naked Singles:");
501        assert!(formatted[1].contains("#1 R1C1=1"));
502        assert_eq!(formatted[2], "Hidden Singles:");
503        assert!(formatted[3].contains("#2 R2C1=3"));
504        assert_eq!(formatted[4], "Hidden Pairs:");
505        assert!(formatted[5].contains("#3 -6@R3C1"));
506    }
507}