Skip to main content

use_board/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, num::NonZeroU8, str::FromStr};
5use std::error::Error;
6
7/// Commonly used board primitives.
8pub mod prelude {
9    pub use crate::{
10        AssemblySide, BoardId, BoardLayer, BoardLayerParseError, BoardName, BoardSide,
11        BoardSideParseError, BoardTextError, LayerCount, LayerCountError,
12    };
13}
14
15/// Errors returned by non-empty board text wrappers.
16#[derive(Clone, Copy, Debug, Eq, PartialEq)]
17pub enum BoardTextError {
18    /// The text was empty after trimming whitespace.
19    Empty,
20}
21
22impl fmt::Display for BoardTextError {
23    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::Empty => formatter.write_str("board text cannot be empty"),
26        }
27    }
28}
29
30impl Error for BoardTextError {}
31
32/// A stable board identifier.
33#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
34pub struct BoardId(String);
35
36impl BoardId {
37    /// Creates a board ID from non-empty text.
38    ///
39    /// # Errors
40    ///
41    /// Returns [`BoardTextError::Empty`] when the trimmed value is empty.
42    pub fn new(value: impl AsRef<str>) -> Result<Self, BoardTextError> {
43        non_empty_text(value).map(Self)
44    }
45
46    /// Returns the ID text.
47    #[must_use]
48    pub fn as_str(&self) -> &str {
49        &self.0
50    }
51}
52
53impl AsRef<str> for BoardId {
54    fn as_ref(&self) -> &str {
55        self.as_str()
56    }
57}
58
59impl fmt::Display for BoardId {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        formatter.write_str(self.as_str())
62    }
63}
64
65impl FromStr for BoardId {
66    type Err = BoardTextError;
67
68    fn from_str(value: &str) -> Result<Self, Self::Err> {
69        Self::new(value)
70    }
71}
72
73/// A non-empty board name.
74#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
75pub struct BoardName(String);
76
77impl BoardName {
78    /// Creates a board name from non-empty text.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`BoardTextError::Empty`] when the trimmed value is empty.
83    pub fn new(value: impl AsRef<str>) -> Result<Self, BoardTextError> {
84        non_empty_text(value).map(Self)
85    }
86
87    /// Returns the board name text.
88    #[must_use]
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92}
93
94impl AsRef<str> for BoardName {
95    fn as_ref(&self) -> &str {
96        self.as_str()
97    }
98}
99
100impl fmt::Display for BoardName {
101    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
102        formatter.write_str(self.as_str())
103    }
104}
105
106impl FromStr for BoardName {
107    type Err = BoardTextError;
108
109    fn from_str(value: &str) -> Result<Self, Self::Err> {
110        Self::new(value)
111    }
112}
113
114/// Board side vocabulary.
115#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
116pub enum BoardSide {
117    Top,
118    Bottom,
119}
120
121impl fmt::Display for BoardSide {
122    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123        formatter.write_str(match self {
124            Self::Top => "top",
125            Self::Bottom => "bottom",
126        })
127    }
128}
129
130impl FromStr for BoardSide {
131    type Err = BoardSideParseError;
132
133    fn from_str(value: &str) -> Result<Self, Self::Err> {
134        let trimmed = value.trim();
135        if trimmed.is_empty() {
136            return Err(BoardSideParseError::Empty);
137        }
138
139        match trimmed.to_ascii_lowercase().as_str() {
140            "top" => Ok(Self::Top),
141            "bottom" => Ok(Self::Bottom),
142            _ => Err(BoardSideParseError::Unknown),
143        }
144    }
145}
146
147/// Errors returned while parsing board sides.
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub enum BoardSideParseError {
150    /// The side was empty after trimming whitespace.
151    Empty,
152    /// The side was not part of the fixed vocabulary.
153    Unknown,
154}
155
156impl fmt::Display for BoardSideParseError {
157    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
158        match self {
159            Self::Empty => formatter.write_str("board side cannot be empty"),
160            Self::Unknown => formatter.write_str("unknown board side"),
161        }
162    }
163}
164
165impl Error for BoardSideParseError {}
166
167/// Simple board layer vocabulary.
168#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
169pub enum BoardLayer {
170    TopCopper,
171    BottomCopper,
172    InnerCopper(u8),
173    SilkscreenTop,
174    SilkscreenBottom,
175    SolderMaskTop,
176    SolderMaskBottom,
177    Mechanical,
178    Custom(String),
179}
180
181impl fmt::Display for BoardLayer {
182    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
183        match self {
184            Self::TopCopper => formatter.write_str("top-copper"),
185            Self::BottomCopper => formatter.write_str("bottom-copper"),
186            Self::InnerCopper(index) => write!(formatter, "inner-copper-{index}"),
187            Self::SilkscreenTop => formatter.write_str("silkscreen-top"),
188            Self::SilkscreenBottom => formatter.write_str("silkscreen-bottom"),
189            Self::SolderMaskTop => formatter.write_str("solder-mask-top"),
190            Self::SolderMaskBottom => formatter.write_str("solder-mask-bottom"),
191            Self::Mechanical => formatter.write_str("mechanical"),
192            Self::Custom(value) => formatter.write_str(value),
193        }
194    }
195}
196
197impl FromStr for BoardLayer {
198    type Err = BoardLayerParseError;
199
200    fn from_str(value: &str) -> Result<Self, Self::Err> {
201        let trimmed = value.trim();
202        if trimmed.is_empty() {
203            return Err(BoardLayerParseError::Empty);
204        }
205
206        let normalized = normalized_token(trimmed);
207        if let Some(index) = normalized.strip_prefix("inner-copper-") {
208            return index
209                .parse::<NonZeroU8>()
210                .map(|value| Self::InnerCopper(value.get()))
211                .map_err(|_| BoardLayerParseError::InvalidInnerCopperIndex);
212        }
213
214        match normalized.as_str() {
215            "top-copper" => Ok(Self::TopCopper),
216            "bottom-copper" => Ok(Self::BottomCopper),
217            "silkscreen-top" => Ok(Self::SilkscreenTop),
218            "silkscreen-bottom" => Ok(Self::SilkscreenBottom),
219            "solder-mask-top" => Ok(Self::SolderMaskTop),
220            "solder-mask-bottom" => Ok(Self::SolderMaskBottom),
221            "mechanical" => Ok(Self::Mechanical),
222            _ => Ok(Self::Custom(trimmed.to_string())),
223        }
224    }
225}
226
227/// Errors returned while parsing board layers.
228#[derive(Clone, Copy, Debug, Eq, PartialEq)]
229pub enum BoardLayerParseError {
230    /// The layer was empty after trimming whitespace.
231    Empty,
232    /// An inner copper layer index was zero or not numeric.
233    InvalidInnerCopperIndex,
234}
235
236impl fmt::Display for BoardLayerParseError {
237    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
238        match self {
239            Self::Empty => formatter.write_str("board layer cannot be empty"),
240            Self::InvalidInnerCopperIndex => {
241                formatter.write_str("inner copper layer index must be non-zero")
242            },
243        }
244    }
245}
246
247impl Error for BoardLayerParseError {}
248
249/// A non-zero board layer count.
250#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
251pub struct LayerCount(NonZeroU8);
252
253impl LayerCount {
254    /// Creates a non-zero board layer count.
255    ///
256    /// # Errors
257    ///
258    /// Returns [`LayerCountError::Zero`] when `value` is zero.
259    pub fn new(value: u8) -> Result<Self, LayerCountError> {
260        NonZeroU8::new(value).map(Self).ok_or(LayerCountError::Zero)
261    }
262
263    /// Returns the layer count.
264    #[must_use]
265    pub const fn get(self) -> u8 {
266        self.0.get()
267    }
268}
269
270impl fmt::Display for LayerCount {
271    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
272        self.get().fmt(formatter)
273    }
274}
275
276/// Errors returned while constructing layer counts.
277#[derive(Clone, Copy, Debug, Eq, PartialEq)]
278pub enum LayerCountError {
279    /// Zero is not a valid layer count.
280    Zero,
281}
282
283impl fmt::Display for LayerCountError {
284    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
285        match self {
286            Self::Zero => formatter.write_str("layer count must be non-zero"),
287        }
288    }
289}
290
291impl Error for LayerCountError {}
292
293/// Assembly side vocabulary.
294#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
295pub enum AssemblySide {
296    Top,
297    Bottom,
298    Both,
299    Unknown,
300}
301
302impl fmt::Display for AssemblySide {
303    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304        formatter.write_str(match self {
305            Self::Top => "top",
306            Self::Bottom => "bottom",
307            Self::Both => "both",
308            Self::Unknown => "unknown",
309        })
310    }
311}
312
313fn non_empty_text(value: impl AsRef<str>) -> Result<String, BoardTextError> {
314    let trimmed = value.as_ref().trim();
315    if trimmed.is_empty() {
316        Err(BoardTextError::Empty)
317    } else {
318        Ok(trimmed.to_string())
319    }
320}
321
322fn normalized_token(value: &str) -> String {
323    value.trim().to_ascii_lowercase().replace(['_', ' '], "-")
324}
325
326#[cfg(test)]
327mod tests {
328    use super::{BoardLayer, BoardName, BoardSide, BoardTextError, LayerCount, LayerCountError};
329
330    #[test]
331    fn accepts_valid_board_names() -> Result<(), BoardTextError> {
332        let name = BoardName::new("main board")?;
333
334        assert_eq!(name.as_str(), "main board");
335        Ok(())
336    }
337
338    #[test]
339    fn rejects_empty_board_names() {
340        assert_eq!(BoardName::new(""), Err(BoardTextError::Empty));
341    }
342
343    #[test]
344    fn accepts_valid_layer_counts() -> Result<(), LayerCountError> {
345        let count = LayerCount::new(2)?;
346
347        assert_eq!(count.get(), 2);
348        Ok(())
349    }
350
351    #[test]
352    fn rejects_zero_layer_counts() {
353        assert_eq!(LayerCount::new(0), Err(LayerCountError::Zero));
354    }
355
356    #[test]
357    fn displays_and_parses_board_sides() -> Result<(), Box<dyn std::error::Error>> {
358        assert_eq!("top".parse::<BoardSide>()?, BoardSide::Top);
359        assert_eq!(BoardSide::Bottom.to_string(), "bottom");
360        Ok(())
361    }
362
363    #[test]
364    fn displays_and_parses_board_layers() -> Result<(), Box<dyn std::error::Error>> {
365        assert_eq!("top copper".parse::<BoardLayer>()?, BoardLayer::TopCopper);
366        assert_eq!(
367            "inner-copper-2".parse::<BoardLayer>()?,
368            BoardLayer::InnerCopper(2)
369        );
370        assert_eq!(BoardLayer::SolderMaskTop.to_string(), "solder-mask-top");
371        Ok(())
372    }
373}