1use std::fmt;
11use std::path::Path;
12use std::str::FromStr;
13
14use anyhow::Result;
15use serde::{Deserialize, Deserializer, Serialize, Serializer};
16use wsb_prj::settings::ProjectSettings;
17
18#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
23pub struct BoardRef {
24 pub owner: String,
25 pub number: i64,
26}
27
28impl fmt::Display for BoardRef {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 write!(f, "{}/{}", self.owner, self.number)
31 }
32}
33
34impl FromStr for BoardRef {
35 type Err = String;
36
37 fn from_str(text: &str) -> Result<Self, Self::Err> {
38 let (owner, number) = text
39 .split_once('/')
40 .ok_or_else(|| format!("{text:?} is not <owner>/<number>"))?;
41 let owner = owner.trim();
42 if owner.is_empty()
43 || !owner
44 .chars()
45 .all(|c| c.is_ascii_alphanumeric() || c == '-')
46 {
47 return Err(format!("{owner:?} is not a GitHub owner"));
48 }
49 let number: i64 = number
50 .trim()
51 .trim_start_matches('#')
52 .parse()
53 .map_err(|_| format!("{number:?} is not a board number"))?;
54 if number < 1 {
55 return Err(format!("{number} is not a board number"));
56 }
57 Ok(Self {
58 owner: owner.to_string(),
59 number,
60 })
61 }
62}
63
64impl Serialize for BoardRef {
65 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
66 serializer.serialize_str(&self.to_string())
67 }
68}
69
70impl<'de> Deserialize<'de> for BoardRef {
71 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
72 let text = String::deserialize(deserializer)?;
73 text.parse().map_err(serde::de::Error::custom)
74 }
75}
76
77pub fn read(checkout: &Path) -> Result<Vec<BoardRef>> {
81 ProjectSettings::load(checkout)?
82 .boards
83 .iter()
84 .map(|board| board.parse::<BoardRef>().map_err(anyhow::Error::msg))
85 .collect()
86}
87
88pub fn write(checkout: &Path, boards: &[BoardRef]) -> Result<()> {
91 let mut settings = ProjectSettings::load(checkout)?;
92 settings.boards = boards.iter().map(BoardRef::to_string).collect();
93 settings.save(checkout)
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
101 fn a_board_reference_is_owner_and_number() {
102 let board: BoardRef = "machinefabric/7".parse().expect("a reference");
103 assert_eq!(board.owner, "machinefabric");
104 assert_eq!(board.number, 7);
105 assert_eq!(board.to_string(), "machinefabric/7");
106 assert_eq!("machinefabric/#7".parse::<BoardRef>(), Ok(board));
107
108 for refused in ["machinefabric", "machinefabric/x", "/7", "a b/7", "machinefabric/0"] {
109 assert!(refused.parse::<BoardRef>().is_err(), "{refused} was read as a board");
110 }
111 }
112
113 #[test]
114 fn a_projects_boards_round_trip_through_its_settings() {
115 let dir = tempfile::tempdir().expect("a checkout");
116 assert!(read(dir.path()).expect("read").is_empty());
117
118 let boards: Vec<BoardRef> = vec!["machinefabric/7".parse().unwrap(), "someone/2".parse().unwrap()];
119 write(dir.path(), &boards).expect("written");
120 assert_eq!(read(dir.path()).expect("read"), boards);
121 let text = std::fs::read_to_string(ProjectSettings::path(dir.path())).expect("on disk");
122 assert!(text.contains("\"machinefabric/7\""), "boards are written as people name them: {text}");
123
124 let mut settings = ProjectSettings::load(dir.path()).unwrap();
126 settings.ignored_submodules = vec!["vendor".to_string()];
127 settings.save(dir.path()).unwrap();
128 write(dir.path(), &boards[..1]).unwrap();
129 assert_eq!(ProjectSettings::load(dir.path()).unwrap().ignored_submodules, vec!["vendor".to_string()]);
130 }
131}