Skip to main content

wsb_github/
boards.rs

1//! The boards a project references.
2//!
3//! A GitHub Projects board belongs to an owner, not to a repository, and one
4//! board can span several projects' repositories. So a project does not own a
5//! board: it keeps a list of the boards it works with, in its own state
6//! (`$PRJ/.state/projects/<project>/github.json`), and the boards themselves are read
7//! from the machine's mirror, which every project referencing a board shares.
8
9use std::fmt;
10use std::path::PathBuf;
11use std::str::FromStr;
12
13use anyhow::{bail, Context, Result};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16/// The file a project's GitHub references live in, under its `.wsb`.
17pub const FILE: &str = "github.json";
18
19/// One board, as a person names it: its owner and its number.
20///
21/// The number rather than the node id, because the number is what GitHub
22/// shows and what a person can type; the id is looked up in the mirror.
23#[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/// What a project says about GitHub.
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(deny_unknown_fields)]
81pub struct ProjectGitHub {
82    /// The boards this project works with, in the order it lists them.
83    #[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
91/// What a project references. A project with no file references nothing,
92/// which is an ordinary state; a file that does not read is refused.
93pub 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
113/// Write what a project references, whole.
114pub 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    // Written beside and renamed, so a reader never sees half a file.
121    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}