Skip to main content

condor_grid/grid/
mod.rs

1//! Rectangular 4-connected map substrate shared by all discrete grid lanes.
2//!
3//! [`Grid`] owns walkability ([`Cell`]), positive per-cell
4//! [`Grid::traversal_cost`], and an optional [`reachability::GridReachabilityIndex`]
5//! for early same-component checks. Walkability edits invalidate that index; rebuild
6//! it with [`Grid::index_reachability`]. Prefer [`GridBuilder`] / [`Grid::builder`]
7//! for bulk construction, or [`Grid::try_from_rows`] / [`grid!`](crate::grid!) for
8//! small textual fixtures.
9
10use std::{error::Error, fmt};
11
12use crate::point::Point;
13
14/// Optional same-component reachability index for early no-path rejection.
15pub mod reachability;
16
17/// Walkability state of a single grid cell.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum Cell {
20    /// Passable; participates in 4-connected search and may carry a traversal cost.
21    Open,
22    /// Impassable; excluded from neighbors and path validation.
23    Blocked,
24}
25
26/// Storage buffer that could not be allocated while constructing a grid.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum GridStorage {
30    /// Walkability cell array.
31    Cells,
32    /// Per-cell `traversal_cost` array.
33    TraversalCosts,
34}
35
36impl fmt::Display for GridStorage {
37    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            Self::Cells => formatter.write_str("cells"),
40            Self::TraversalCosts => formatter.write_str("traversal costs"),
41        }
42    }
43}
44
45/// Error returned when a grid cannot be constructed.
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[non_exhaustive]
48pub enum GridBuildError {
49    /// Width must be positive.
50    ZeroWidth,
51    /// Height must be positive.
52    ZeroHeight,
53    /// `width * height` overflowed `usize`.
54    CellCountOverflow { width: usize, height: usize },
55    /// Backing vector reserve failed for the named buffer.
56    CapacityExceeded { storage: GridStorage },
57    /// Textual row input was empty.
58    EmptyRows,
59    /// A textual row had zero characters.
60    ZeroWidthRow,
61    /// A textual row did not match the first row's width.
62    RaggedRow {
63        row: usize,
64        expected_width: usize,
65        actual_width: usize,
66    },
67    /// Unsupported character in textual row input.
68    InvalidRowCharacter {
69        row: usize,
70        column: usize,
71        character: char,
72    },
73    /// A queued builder edit failed validation.
74    Edit(GridEditError),
75}
76
77impl fmt::Display for GridBuildError {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        match self {
80            Self::ZeroWidth => formatter.write_str("grid width must be greater than zero"),
81            Self::ZeroHeight => formatter.write_str("grid height must be greater than zero"),
82            Self::CellCountOverflow { width, height } => {
83                write!(formatter, "grid dimensions {width}x{height} overflow usize")
84            }
85            Self::CapacityExceeded { storage } => {
86                write!(formatter, "grid {storage} exceed available capacity")
87            }
88            Self::EmptyRows => formatter.write_str("grid rows must not be empty"),
89            Self::ZeroWidthRow => formatter.write_str("grid rows must not be zero-width"),
90            Self::RaggedRow {
91                row,
92                expected_width,
93                actual_width,
94            } => write!(
95                formatter,
96                "grid row {row} has width {actual_width}, expected {expected_width}"
97            ),
98            Self::InvalidRowCharacter {
99                row,
100                column,
101                character,
102            } => write!(
103                formatter,
104                "grid row {row}, column {column} contains unsupported character {character:?}"
105            ),
106            Self::Edit(error) => write!(formatter, "grid edit failed: {error}"),
107        }
108    }
109}
110
111impl Error for GridBuildError {
112    fn source(&self) -> Option<&(dyn Error + 'static)> {
113        match self {
114            Self::Edit(error) => Some(error),
115            _ => None,
116        }
117    }
118}
119
120impl From<GridEditError> for GridBuildError {
121    fn from(error: GridEditError) -> Self {
122        Self::Edit(error)
123    }
124}
125
126/// Error returned when a requested grid edit violates grid invariants.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128#[non_exhaustive]
129pub enum GridEditError {
130    /// Target cell lies outside `0..width` × `0..height`.
131    OutOfBounds {
132        point: Point,
133        width: usize,
134        height: usize,
135    },
136    /// Traversal cost cannot be set on a blocked cell.
137    BlockedCell { point: Point },
138    /// Traversal cost must be strictly positive.
139    ZeroTraversalCost { point: Point },
140}
141
142impl fmt::Display for GridEditError {
143    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
144        match self {
145            Self::OutOfBounds {
146                point,
147                width,
148                height,
149            } => write!(
150                formatter,
151                "point ({}, {}) is outside grid bounds {width}x{height}",
152                point.x, point.y
153            ),
154            Self::BlockedCell { point } => write!(
155                formatter,
156                "cannot set traversal cost for blocked cell ({}, {})",
157                point.x, point.y
158            ),
159            Self::ZeroTraversalCost { point } => write!(
160                formatter,
161                "traversal cost at ({}, {}) must be greater than zero",
162                point.x, point.y
163            ),
164        }
165    }
166}
167
168impl Error for GridEditError {}
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum GridBuilderEdit {
172    Cell { point: Point, cell: Cell },
173    Cost { point: Point, cost: usize },
174}
175
176/// Builder for applying validated bulk edits to a new [`Grid`].
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct GridBuilder {
179    width: usize,
180    height: usize,
181    edits: Vec<GridBuilderEdit>,
182    index_reachability: bool,
183}
184
185impl GridBuilder {
186    /// Queues edits for a `width`×`height` grid; dimensions are validated on [`Self::build`].
187    ///
188    /// Starts fully open with unit traversal costs. Call [`Self::index_reachability`]
189    /// if the built grid should short-circuit disconnected start/goal pairs.
190    #[must_use]
191    pub const fn new(width: usize, height: usize) -> Self {
192        Self {
193            width,
194            height,
195            edits: Vec::new(),
196            index_reachability: false,
197        }
198    }
199
200    /// Marks all supplied cells as blocked.
201    #[must_use]
202    pub fn blocked<I, P>(mut self, points: I) -> Self
203    where
204        I: IntoIterator<Item = P>,
205        P: Into<Point>,
206    {
207        self.edits
208            .extend(points.into_iter().map(|point| GridBuilderEdit::Cell {
209                point: point.into(),
210                cell: Cell::Blocked,
211            }));
212        self
213    }
214
215    /// Marks all supplied cells as open.
216    #[must_use]
217    pub fn open<I, P>(mut self, points: I) -> Self
218    where
219        I: IntoIterator<Item = P>,
220        P: Into<Point>,
221    {
222        self.edits
223            .extend(points.into_iter().map(|point| GridBuilderEdit::Cell {
224                point: point.into(),
225                cell: Cell::Open,
226            }));
227        self
228    }
229
230    /// Sets traversal costs from `(point, cost)` pairs.
231    #[must_use]
232    pub fn costs<I, P>(mut self, costs: I) -> Self
233    where
234        I: IntoIterator<Item = (P, usize)>,
235        P: Into<Point>,
236    {
237        self.edits.extend(
238            costs
239                .into_iter()
240                .map(|(point, cost)| GridBuilderEdit::Cost {
241                    point: point.into(),
242                    cost,
243                }),
244        );
245        self
246    }
247
248    /// Builds a reachability index after all edits have been applied.
249    #[must_use]
250    pub const fn index_reachability(mut self) -> Self {
251        self.index_reachability = true;
252        self
253    }
254
255    /// Builds the grid and validates every queued edit in insertion order.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`GridBuildError`] for invalid dimensions, allocation failure,
260    /// or an invalid queued edit.
261    pub fn build(self) -> Result<Grid, GridBuildError> {
262        let mut grid = Grid::new(self.width, self.height)?;
263        for edit in self.edits {
264            match edit {
265                GridBuilderEdit::Cell { point, cell } => grid.set_cell(point, cell)?,
266                GridBuilderEdit::Cost { point, cost } => {
267                    grid.set_traversal_cost(point, cost)?;
268                }
269            }
270        }
271        if self.index_reachability {
272            grid.index_reachability();
273        }
274        Ok(grid)
275    }
276}
277
278/// Dense row-major rectangular map: walkability, `traversal_cost`, optional reachability.
279///
280/// Shared substrate for online [`crate::Pathfinder`] algorithms (4-connected). Open cells
281/// default to unit cost; blocked cells are excluded from neighbors. Cached
282/// [`reachability::GridReachabilityIndex`] is optional and cleared on walkability edits.
283#[derive(Debug, Clone, PartialEq, Eq)]
284pub struct Grid {
285    width: usize,
286    height: usize,
287    cells: Vec<Cell>,
288    traversal_costs: Vec<usize>,
289    reachability: Option<reachability::GridReachabilityIndex>,
290}
291
292impl Grid {
293    /// Creates a grid with non-zero dimensions.
294    ///
295    /// # Errors
296    ///
297    /// Returns [`GridBuildError`] when either dimension is zero, the cell count
298    /// overflows, or backing storage cannot be reserved.
299    pub fn new(width: usize, height: usize) -> Result<Self, GridBuildError> {
300        if width == 0 {
301            return Err(GridBuildError::ZeroWidth);
302        }
303        if height == 0 {
304            return Err(GridBuildError::ZeroHeight);
305        }
306        let Some(cell_count) = width.checked_mul(height) else {
307            return Err(GridBuildError::CellCountOverflow { width, height });
308        };
309
310        let mut cells = Vec::new();
311        cells
312            .try_reserve_exact(cell_count)
313            .map_err(|_| GridBuildError::CapacityExceeded {
314                storage: GridStorage::Cells,
315            })?;
316        cells.resize(cell_count, Cell::Open);
317
318        let mut traversal_costs = Vec::new();
319        traversal_costs.try_reserve_exact(cell_count).map_err(|_| {
320            GridBuildError::CapacityExceeded {
321                storage: GridStorage::TraversalCosts,
322            }
323        })?;
324        traversal_costs.resize(cell_count, 1);
325
326        Ok(Self {
327            width,
328            height,
329            cells,
330            traversal_costs,
331            reachability: None,
332        })
333    }
334
335    /// Starts a builder for a grid with the supplied dimensions.
336    #[must_use]
337    pub const fn builder(width: usize, height: usize) -> GridBuilder {
338        GridBuilder::new(width, height)
339    }
340
341    /// Parses a rectangular grid from textual rows.
342    ///
343    /// `.` creates an open cell with cost 1, `#` creates a blocked cell, and
344    /// `1` through `9` create open cells with the corresponding traversal cost.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`GridBuildError`] for empty, zero-width, ragged, or invalid
349    /// input, or when the resulting grid cannot be allocated.
350    pub fn try_from_rows<I, S>(rows: I) -> Result<Self, GridBuildError>
351    where
352        I: IntoIterator<Item = S>,
353        S: AsRef<str>,
354    {
355        let rows: Vec<S> = rows.into_iter().collect();
356        let Some(first) = rows.first() else {
357            return Err(GridBuildError::EmptyRows);
358        };
359        let width = first.as_ref().chars().count();
360        if width == 0 {
361            return Err(GridBuildError::ZeroWidthRow);
362        }
363
364        let mut grid = Self::new(width, rows.len())?;
365        for (row, source) in rows.iter().enumerate() {
366            let actual_width = source.as_ref().chars().count();
367            if actual_width != width {
368                return Err(GridBuildError::RaggedRow {
369                    row,
370                    expected_width: width,
371                    actual_width,
372                });
373            }
374            for (column, character) in source.as_ref().chars().enumerate() {
375                let point = Point::new(column, row);
376                match character {
377                    '.' | '1' => {}
378                    '#' => grid.set_cell(point, Cell::Blocked)?,
379                    '2'..='9' => {
380                        let cost = character.to_digit(10).expect("matched an ASCII digit") as usize;
381                        grid.set_traversal_cost(point, cost)?;
382                    }
383                    character => {
384                        return Err(GridBuildError::InvalidRowCharacter {
385                            row,
386                            column,
387                            character,
388                        });
389                    }
390                }
391            }
392        }
393        Ok(grid)
394    }
395
396    /// Builds or rebuilds the cached reachability index from the current cells.
397    pub fn index_reachability(&mut self) {
398        self.reachability = Some(reachability::GridReachabilityIndex::from_grid(self));
399    }
400
401    /// Returns whether `start` and `goal` share a walkable 4-way component.
402    ///
403    /// Without a prior [`Self::index_reachability`] call, always returns `true`
404    /// so search is not short-circuited.
405    #[must_use]
406    pub fn is_reachable(&self, start: Point, goal: Point) -> bool {
407        if let Some(reachability) = &self.reachability {
408            reachability.is_reachable(start, goal)
409        } else {
410            true
411        }
412    }
413
414    /// Column count (cells along x).
415    #[must_use]
416    pub const fn width(&self) -> usize {
417        self.width
418    }
419
420    /// Row count (cells along y).
421    #[must_use]
422    pub const fn height(&self) -> usize {
423        self.height
424    }
425
426    /// Total cells (`width * height`).
427    #[must_use]
428    pub fn cell_count(&self) -> usize {
429        self.cells.len()
430    }
431
432    /// Whether `point` lies inside the grid bounds (walkability is separate).
433    #[must_use]
434    pub fn contains(&self, point: Point) -> bool {
435        self.index_of(point).is_some()
436    }
437
438    /// Cell state at `point`, or `None` when out of bounds.
439    #[must_use]
440    pub fn cell(&self, point: Point) -> Option<Cell> {
441        self.index_of(point).map(|index| self.cells[index])
442    }
443
444    /// Sets walkability; clears the cached reachability index when the value changes.
445    ///
446    /// Blocking an open cell hides its traversal cost from queries but retains the
447    /// stored cost so reopening restores it.
448    ///
449    /// # Errors
450    ///
451    /// Returns [`GridEditError::OutOfBounds`] if `point` is outside the grid.
452    pub fn set_cell(&mut self, point: Point, cell: Cell) -> Result<(), GridEditError> {
453        let Some(index) = self.index_of(point) else {
454            return Err(GridEditError::OutOfBounds {
455                point,
456                width: self.width,
457                height: self.height,
458            });
459        };
460
461        if self.cells[index] != cell {
462            self.cells[index] = cell;
463            self.reachability = None;
464        }
465        Ok(())
466    }
467
468    /// In-bounds and [`Cell::Open`]; out-of-bounds and blocked cells are not walkable.
469    #[must_use]
470    pub fn is_walkable(&self, point: Point) -> bool {
471        matches!(self.cell(point), Some(Cell::Open))
472    }
473
474    /// Checks that every point is open and every consecutive pair is an orthogonal unit step.
475    #[must_use]
476    pub fn path_is_walkable(&self, points: &[Point]) -> bool {
477        points.iter().all(|&p| self.is_walkable(p))
478            && points
479                .windows(2)
480                .all(|pair| self.segment_is_walkable(pair[0], pair[1]))
481    }
482
483    /// Returns whether `start` and `end` form a single orthogonal unit edge between open cells.
484    #[must_use]
485    pub fn segment_is_walkable(&self, start: Point, end: Point) -> bool {
486        if start == end {
487            return self.is_walkable(start);
488        }
489
490        let dx = (start.x as i64 - end.x as i64).abs();
491        let dy = (start.y as i64 - end.y as i64).abs();
492
493        if (dx == 1 && dy == 0) || (dx == 0 && dy == 1) {
494            self.is_walkable(start) && self.is_walkable(end)
495        } else {
496            false
497        }
498    }
499
500    /// Returns the traversal cost for an open cell, or `None` if blocked or out of bounds.
501    #[must_use]
502    pub fn traversal_cost(&self, point: Point) -> Option<usize> {
503        let index = self.index_of(point)?;
504        self.is_walkable(point)
505            .then_some(self.traversal_costs[index])
506    }
507
508    /// Sets a positive traversal cost for a walkable cell.
509    ///
510    /// # Errors
511    ///
512    /// Returns [`GridEditError`] if the cost is zero, the point is outside the
513    /// grid, or the cell is blocked.
514    pub fn set_traversal_cost(&mut self, point: Point, cost: usize) -> Result<(), GridEditError> {
515        if cost == 0 {
516            return Err(GridEditError::ZeroTraversalCost { point });
517        }
518
519        let Some(index) = self.index_of(point) else {
520            return Err(GridEditError::OutOfBounds {
521                point,
522                width: self.width,
523                height: self.height,
524            });
525        };
526        if !matches!(self.cells[index], Cell::Open) {
527            return Err(GridEditError::BlockedCell { point });
528        }
529
530        self.traversal_costs[index] = cost;
531        Ok(())
532    }
533
534    /// Walkable 4-connected neighbors of `point`, in fixed axis order.
535    #[must_use]
536    pub fn neighbors4(&self, point: Point) -> Vec<Point> {
537        let mut neighbors = Vec::with_capacity(4);
538
539        if point.x > 0 {
540            let candidate = Point::new(point.x - 1, point.y);
541            if self.is_walkable(candidate) {
542                neighbors.push(candidate);
543            }
544        }
545
546        if point.x + 1 < self.width {
547            let candidate = Point::new(point.x + 1, point.y);
548            if self.is_walkable(candidate) {
549                neighbors.push(candidate);
550            }
551        }
552
553        if point.y > 0 {
554            let candidate = Point::new(point.x, point.y - 1);
555            if self.is_walkable(candidate) {
556                neighbors.push(candidate);
557            }
558        }
559
560        if point.y + 1 < self.height {
561            let candidate = Point::new(point.x, point.y + 1);
562            if self.is_walkable(candidate) {
563                neighbors.push(candidate);
564            }
565        }
566
567        neighbors
568    }
569
570    /// Dense row-major cell index (`y * width + x`), or `None` when out of bounds.
571    #[must_use]
572    #[doc(hidden)]
573    pub fn index_of(&self, point: Point) -> Option<usize> {
574        if point.x >= self.width || point.y >= self.height {
575            return None;
576        }
577
578        Some((point.y * self.width) + point.x)
579    }
580
581    /// Inverse of [`Self::index_of`]: reconstructs `(x, y)` from a dense row-major index.
582    #[must_use]
583    #[doc(hidden)]
584    pub fn point_from_index(&self, index: usize) -> Point {
585        let x = index % self.width;
586        let y = index / self.width;
587        Point::new(x, y)
588    }
589}
590
591/// Constructs a [`Grid`] from textual rows using [`Grid::try_from_rows`].
592#[macro_export]
593macro_rules! grid {
594    () => {
595        $crate::Grid::try_from_rows(::core::iter::empty::<&str>())
596    };
597    ($($row:expr),+ $(,)?) => {
598        $crate::Grid::try_from_rows([$($row),+])
599    };
600}
601
602#[cfg(test)]
603mod tests {
604    use super::{Cell, Grid, GridBuildError, GridEditError, Point};
605
606    #[test]
607    fn blocking_and_reopening_preserves_custom_traversal_cost() {
608        let mut grid = Grid::new(3, 1).expect("grid dimensions are valid");
609        let point = Point::new(1, 0);
610
611        assert_eq!(grid.set_traversal_cost(point, 5), Ok(()));
612        assert_eq!(grid.traversal_cost(point), Some(5));
613
614        assert_eq!(grid.set_cell(point, Cell::Blocked), Ok(()));
615        assert_eq!(grid.traversal_cost(point), None);
616
617        assert_eq!(grid.set_cell(point, Cell::Open), Ok(()));
618        assert_eq!(grid.traversal_cost(point), Some(5));
619    }
620
621    #[test]
622    fn builder_applies_bulk_edits_and_indexes_reachability() {
623        let grid = Grid::builder(3, 2)
624            .blocked([(1, 0), (1, 1), (0, 1)])
625            .open([(0, 1)])
626            .costs([((2, 1), 4)])
627            .index_reachability()
628            .build()
629            .expect("builder input is valid");
630
631        assert_eq!(grid.cell(Point::new(1, 0)), Some(Cell::Blocked));
632        assert_eq!(grid.cell(Point::new(0, 1)), Some(Cell::Open));
633        assert_eq!(grid.cell(Point::new(1, 1)), Some(Cell::Blocked));
634        assert_eq!(grid.traversal_cost(Point::new(2, 1)), Some(4));
635        assert!(!grid.is_reachable(Point::new(0, 0), Point::new(2, 0)));
636    }
637
638    #[test]
639    fn builder_reports_invalid_edits_with_context() {
640        let error = Grid::builder(2, 2)
641            .blocked([(2, 0)])
642            .build()
643            .expect_err("point is out of bounds");
644
645        assert_eq!(
646            error,
647            GridBuildError::Edit(GridEditError::OutOfBounds {
648                point: Point::new(2, 0),
649                width: 2,
650                height: 2,
651            })
652        );
653    }
654
655    #[test]
656    fn rows_parse_cells_and_costs() {
657        let grid = Grid::try_from_rows([".#1", ".29"]).expect("rows are valid");
658
659        assert_eq!(grid.cell(Point::new(1, 0)), Some(Cell::Blocked));
660        assert_eq!(grid.traversal_cost(Point::new(2, 0)), Some(1));
661        assert_eq!(grid.traversal_cost(Point::new(1, 1)), Some(2));
662        assert_eq!(grid.traversal_cost(Point::new(2, 1)), Some(9));
663    }
664
665    #[test]
666    fn rows_report_shape_and_character_errors() {
667        assert_eq!(
668            Grid::try_from_rows(Vec::<String>::new()),
669            Err(GridBuildError::EmptyRows)
670        );
671        assert_eq!(Grid::try_from_rows([""]), Err(GridBuildError::ZeroWidthRow));
672        assert_eq!(
673            Grid::try_from_rows(["..", "."]),
674            Err(GridBuildError::RaggedRow {
675                row: 1,
676                expected_width: 2,
677                actual_width: 1,
678            })
679        );
680        assert_eq!(
681            Grid::try_from_rows([".x"]),
682            Err(GridBuildError::InvalidRowCharacter {
683                row: 0,
684                column: 1,
685                character: 'x',
686            })
687        );
688    }
689
690    #[test]
691    fn macro_delegates_to_row_parser() {
692        let from_macro = crate::grid![".#", ".3"].expect("rows are valid");
693        let from_parser = Grid::try_from_rows([".#", ".3"]).expect("rows are valid");
694        assert_eq!(from_macro, from_parser);
695        assert_eq!(crate::grid!(), Err(GridBuildError::EmptyRows));
696    }
697
698    #[test]
699    fn builder_accepts_tuple_coordinates_and_builds_index() {
700        let grid = Grid::builder(3, 2)
701            .blocked([(1, 0), (1, 1), (0, 1)])
702            .open([(0, 1)])
703            .costs([((2, 1), 7)])
704            .index_reachability()
705            .build()
706            .expect("builder input is valid");
707
708        assert_eq!(grid.cell(Point::new(1, 0)), Some(Cell::Blocked));
709        assert_eq!(grid.traversal_cost(Point::new(2, 1)), Some(7));
710        assert!(!grid.is_reachable(Point::new(0, 0), Point::new(2, 0)));
711    }
712
713    #[test]
714    fn setters_report_why_an_edit_failed() {
715        let mut grid = Grid::new(2, 2).expect("dimensions are valid");
716
717        assert_eq!(
718            grid.set_cell(Point::new(2, 0), Cell::Blocked),
719            Err(GridEditError::OutOfBounds {
720                point: Point::new(2, 0),
721                width: 2,
722                height: 2,
723            })
724        );
725        grid.set_cell(Point::new(1, 1), Cell::Blocked)
726            .expect("point is in bounds");
727        assert_eq!(
728            grid.set_traversal_cost(Point::new(1, 1), 4),
729            Err(GridEditError::BlockedCell {
730                point: Point::new(1, 1),
731            })
732        );
733        assert_eq!(
734            grid.set_traversal_cost(Point::new(0, 0), 0),
735            Err(GridEditError::ZeroTraversalCost {
736                point: Point::new(0, 0),
737            })
738        );
739    }
740
741    #[test]
742    fn row_parser_and_macro_share_the_same_result() {
743        let parsed = Grid::try_from_rows([".#.", ".39"]).expect("rows are valid");
744        let expanded = crate::grid![".#.", ".39"].expect("rows are valid");
745
746        assert_eq!(expanded, parsed);
747        assert_eq!(parsed.cell(Point::new(1, 0)), Some(Cell::Blocked));
748        assert_eq!(parsed.traversal_cost(Point::new(1, 1)), Some(3));
749        assert_eq!(parsed.traversal_cost(Point::new(2, 1)), Some(9));
750    }
751
752    #[test]
753    fn row_parser_reports_precise_shape_and_character_errors() {
754        assert_eq!(
755            Grid::try_from_rows(Vec::<String>::new()),
756            Err(GridBuildError::EmptyRows)
757        );
758        assert_eq!(Grid::try_from_rows([""]), Err(GridBuildError::ZeroWidthRow));
759        assert_eq!(
760            Grid::try_from_rows(["..", "."]),
761            Err(GridBuildError::RaggedRow {
762                row: 1,
763                expected_width: 2,
764                actual_width: 1,
765            })
766        );
767        assert_eq!(
768            Grid::try_from_rows([".x"]),
769            Err(GridBuildError::InvalidRowCharacter {
770                row: 0,
771                column: 1,
772                character: 'x',
773            })
774        );
775    }
776}