1use std::fmt;
10use std::path::PathBuf;
11use std::str::FromStr;
12
13use anyhow::{bail, Context, Result};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16pub const FILE: &str = "github.json";
18
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
24pub struct BoardRef {
25 pub owner: String,
26 pub number: i64,
27}
28
29impl fmt::Display for BoardRef {
30 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(f, "{}/{}", self.owner, self.number)
32 }
33}
34
35impl FromStr for BoardRef {
36 type Err = String;
37
38 fn from_str(text: &str) -> Result<Self, Self::Err> {
39 let (owner, number) = text
40 .split_once('/')
41 .ok_or_else(|| format!("{text:?} is not <owner>/<number>"))?;
42 let owner = owner.trim();
43 if owner.is_empty()
44 || !owner
45 .chars()
46 .all(|c| c.is_ascii_alphanumeric() || c == '-')
47 {
48 return Err(format!("{owner:?} is not a GitHub owner"));
49 }
50 let number: i64 = number
51 .trim()
52 .trim_start_matches('#')
53 .parse()
54 .map_err(|_| format!("{number:?} is not a board number"))?;
55 if number < 1 {
56 return Err(format!("{number} is not a board number"));
57 }
58 Ok(Self {
59 owner: owner.to_string(),
60 number,
61 })
62 }
63}
64
65impl Serialize for BoardRef {
66 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
67 serializer.serialize_str(&self.to_string())
68 }
69}
70
71impl<'de> Deserialize<'de> for BoardRef {
72 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
73 let text = String::deserialize(deserializer)?;
74 text.parse().map_err(serde::de::Error::custom)
75 }
76}
77
78#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ProjectGitHub {
82 #[serde(default)]
84 pub boards: Vec<BoardRef>,
85}
86
87fn path_of(project: &wsb_prj::Project) -> PathBuf {
88 project.state_dir().join(FILE)
89}
90
91pub fn read(project: &wsb_prj::Project) -> Result<ProjectGitHub> {
94 let path = path_of(project);
95 let text = match std::fs::read_to_string(&path) {
96 Ok(text) => text,
97 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
98 return Ok(ProjectGitHub::default())
99 }
100 Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
101 };
102 let declared: ProjectGitHub = serde_json::from_str(&text)
103 .with_context(|| format!("{} is not a project's GitHub references", path.display()))?;
104 let mut seen = std::collections::BTreeSet::new();
105 for board in &declared.boards {
106 if !seen.insert(board) {
107 bail!("{} names board {board} twice", path.display());
108 }
109 }
110 Ok(declared)
111}
112
113pub fn write(project: &wsb_prj::Project, declared: &ProjectGitHub) -> Result<()> {
115 let path = path_of(project);
116 let dir = project.state_dir();
117 std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
118 let mut text = serde_json::to_string_pretty(declared)?;
119 text.push('\n');
120 let partial = dir.join(format!(".{FILE}.partial"));
122 std::fs::write(&partial, text).with_context(|| format!("cannot write {}", partial.display()))?;
123 std::fs::rename(&partial, &path)
124 .with_context(|| format!("cannot replace {}", path.display()))?;
125 Ok(())
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 #[test]
133 fn a_board_reference_is_owner_and_number() {
134 let board: BoardRef = "machinefabric/7".parse().expect("a reference");
135 assert_eq!(board.owner, "machinefabric");
136 assert_eq!(board.number, 7);
137 assert_eq!(board.to_string(), "machinefabric/7");
138 assert_eq!("machinefabric/#7".parse::<BoardRef>(), Ok(board));
139
140 for refused in ["machinefabric", "machinefabric/x", "/7", "a b/7", "machinefabric/0"] {
141 assert!(refused.parse::<BoardRef>().is_err(), "{refused} was read as a board");
142 }
143 }
144
145 fn project() -> (tempfile::TempDir, wsb_prj::Project) {
146 let dir = tempfile::tempdir().expect("a scratch directory");
147 std::fs::create_dir_all(dir.path().join("alpha/main-worktree")).expect("a project");
148 let prj = wsb_prj::Prj::at(dir.path()).expect("a PRJ");
149 let project = prj.project("alpha").expect("alpha");
150 (dir, project)
151 }
152
153 #[test]
154 fn references_round_trip_and_absence_is_none() {
155 let (_dir, project) = project();
156 assert_eq!(read(&project).expect("read"), ProjectGitHub::default());
157
158 let declared = ProjectGitHub {
159 boards: vec!["machinefabric/7".parse().unwrap(), "someone/2".parse().unwrap()],
160 };
161 write(&project, &declared).expect("written");
162 assert_eq!(read(&project).expect("read"), declared);
163 let text = std::fs::read_to_string(project.state_dir().join(FILE)).expect("on disk");
164 assert!(text.contains("\"machinefabric/7\""), "references are written as people name them: {text}");
165 }
166
167 #[test]
168 fn a_file_that_does_not_read_is_refused() {
169 let (_dir, project) = project();
170 std::fs::create_dir_all(project.state_dir()).unwrap();
171 for bad in [
172 r#"{"boards": ["machinefabric"]}"#,
173 r#"{"boards": [], "repos": []}"#,
174 r#"{"boards": ["a/1", "a/1"]}"#,
175 "not json",
176 ] {
177 std::fs::write(project.state_dir().join(FILE), bad).unwrap();
178 assert!(read(&project).is_err(), "{bad} was accepted");
179 }
180 }
181}