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