wsb-github 0.275.7622

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! The boards a project works from.
//!
//! A GitHub Projects board belongs to an owner, not to a repository, and one
//! board can span several projects' repositories. So a project does not own a
//! board: it lists the boards it works from in `boards` of its committed
//! `.wsb/project.json` ([`wsb_prj::settings::ProjectSettings`]), so every
//! clone of the project agrees, and the boards themselves are read from the
//! machine's mirror, which every project working from a board shares.

use std::fmt;
use std::path::Path;
use std::str::FromStr;

use anyhow::Result;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use wsb_prj::settings::ProjectSettings;

/// One board, as a person names it: its owner and its number.
///
/// The number rather than the node id, because the number is what GitHub
/// shows and what a person can type; the id is looked up in the mirror.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct BoardRef {
    pub owner: String,
    pub number: i64,
}

impl fmt::Display for BoardRef {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}/{}", self.owner, self.number)
    }
}

impl FromStr for BoardRef {
    type Err = String;

    fn from_str(text: &str) -> Result<Self, Self::Err> {
        let (owner, number) = text
            .split_once('/')
            .ok_or_else(|| format!("{text:?} is not <owner>/<number>"))?;
        let owner = owner.trim();
        if owner.is_empty()
            || !owner
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-')
        {
            return Err(format!("{owner:?} is not a GitHub owner"));
        }
        let number: i64 = number
            .trim()
            .trim_start_matches('#')
            .parse()
            .map_err(|_| format!("{number:?} is not a board number"))?;
        if number < 1 {
            return Err(format!("{number} is not a board number"));
        }
        Ok(Self {
            owner: owner.to_string(),
            number,
        })
    }
}

impl Serialize for BoardRef {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for BoardRef {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let text = String::deserialize(deserializer)?;
        text.parse().map_err(serde::de::Error::custom)
    }
}

/// The boards the project checked out at `checkout` works from, in the order
/// it lists them. A project that lists none works from none, which is an
/// ordinary state; a list that does not read is refused.
pub fn read(checkout: &Path) -> Result<Vec<BoardRef>> {
    ProjectSettings::load(checkout)?
        .boards
        .iter()
        .map(|board| board.parse::<BoardRef>().map_err(anyhow::Error::msg))
        .collect()
}

/// Make `boards` the list the project checked out at `checkout` works from,
/// in its `.wsb/project.json`, keeping the rest of its settings.
pub fn write(checkout: &Path, boards: &[BoardRef]) -> Result<()> {
    let mut settings = ProjectSettings::load(checkout)?;
    settings.boards = boards.iter().map(BoardRef::to_string).collect();
    settings.save(checkout)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_board_reference_is_owner_and_number() {
        let board: BoardRef = "machinefabric/7".parse().expect("a reference");
        assert_eq!(board.owner, "machinefabric");
        assert_eq!(board.number, 7);
        assert_eq!(board.to_string(), "machinefabric/7");
        assert_eq!("machinefabric/#7".parse::<BoardRef>(), Ok(board));

        for refused in ["machinefabric", "machinefabric/x", "/7", "a b/7", "machinefabric/0"] {
            assert!(refused.parse::<BoardRef>().is_err(), "{refused} was read as a board");
        }
    }

    #[test]
    fn a_projects_boards_round_trip_through_its_settings() {
        let dir = tempfile::tempdir().expect("a checkout");
        assert!(read(dir.path()).expect("read").is_empty());

        let boards: Vec<BoardRef> = vec!["machinefabric/7".parse().unwrap(), "someone/2".parse().unwrap()];
        write(dir.path(), &boards).expect("written");
        assert_eq!(read(dir.path()).expect("read"), boards);
        let text = std::fs::read_to_string(ProjectSettings::path(dir.path())).expect("on disk");
        assert!(text.contains("\"machinefabric/7\""), "boards are written as people name them: {text}");

        // The rest of the project's settings are kept.
        let mut settings = ProjectSettings::load(dir.path()).unwrap();
        settings.ignored_submodules = vec!["vendor".to_string()];
        settings.save(dir.path()).unwrap();
        write(dir.path(), &boards[..1]).unwrap();
        assert_eq!(ProjectSettings::load(dir.path()).unwrap().ignored_submodules, vec!["vendor".to_string()]);
    }
}