wsb-github 0.258.6441

GitHub issues, pull requests, gists and Projects boards, mirrored once per machine for every project under $PRJ
Documentation
//! The boards a project references.
//!
//! 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 keeps a list of the boards it works with, in its own state
//! (`$PRJ/.state/projects/<project>/github.json`), and the boards themselves are read
//! from the machine's mirror, which every project referencing a board shares.

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

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

/// The file a project's GitHub references live in, under its `.wsb`.
pub const FILE: &str = "github.json";

/// 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)
    }
}

/// What a project says about GitHub.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectGitHub {
    /// The boards this project works with, in the order it lists them.
    #[serde(default)]
    pub boards: Vec<BoardRef>,
}

fn path_of(project: &wsb_prj::Project) -> PathBuf {
    project.state_dir().join(FILE)
}

/// What a project references. A project with no file references nothing,
/// which is an ordinary state; a file that does not read is refused.
pub fn read(project: &wsb_prj::Project) -> Result<ProjectGitHub> {
    let path = path_of(project);
    let text = match std::fs::read_to_string(&path) {
        Ok(text) => text,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(ProjectGitHub::default())
        }
        Err(error) => return Err(error).with_context(|| format!("cannot read {}", path.display())),
    };
    let declared: ProjectGitHub = serde_json::from_str(&text)
        .with_context(|| format!("{} is not a project's GitHub references", path.display()))?;
    let mut seen = std::collections::BTreeSet::new();
    for board in &declared.boards {
        if !seen.insert(board) {
            bail!("{} names board {board} twice", path.display());
        }
    }
    Ok(declared)
}

/// Write what a project references, whole.
pub fn write(project: &wsb_prj::Project, declared: &ProjectGitHub) -> Result<()> {
    let path = path_of(project);
    let dir = project.state_dir();
    std::fs::create_dir_all(&dir).with_context(|| format!("cannot create {}", dir.display()))?;
    let mut text = serde_json::to_string_pretty(declared)?;
    text.push('\n');
    // Written beside and renamed, so a reader never sees half a file.
    let partial = dir.join(format!(".{FILE}.partial"));
    std::fs::write(&partial, text).with_context(|| format!("cannot write {}", partial.display()))?;
    std::fs::rename(&partial, &path)
        .with_context(|| format!("cannot replace {}", path.display()))?;
    Ok(())
}

#[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");
        }
    }

    fn project() -> (tempfile::TempDir, wsb_prj::Project) {
        let dir = tempfile::tempdir().expect("a scratch directory");
        std::fs::create_dir_all(dir.path().join("alpha/main-worktree")).expect("a project");
        let prj = wsb_prj::Prj::at(dir.path()).expect("a PRJ");
        let project = prj.project("alpha").expect("alpha");
        (dir, project)
    }

    #[test]
    fn references_round_trip_and_absence_is_none() {
        let (_dir, project) = project();
        assert_eq!(read(&project).expect("read"), ProjectGitHub::default());

        let declared = ProjectGitHub {
            boards: vec!["machinefabric/7".parse().unwrap(), "someone/2".parse().unwrap()],
        };
        write(&project, &declared).expect("written");
        assert_eq!(read(&project).expect("read"), declared);
        let text = std::fs::read_to_string(project.state_dir().join(FILE)).expect("on disk");
        assert!(text.contains("\"machinefabric/7\""), "references are written as people name them: {text}");
    }

    #[test]
    fn a_file_that_does_not_read_is_refused() {
        let (_dir, project) = project();
        std::fs::create_dir_all(project.state_dir()).unwrap();
        for bad in [
            r#"{"boards": ["machinefabric"]}"#,
            r#"{"boards": [], "repos": []}"#,
            r#"{"boards": ["a/1", "a/1"]}"#,
            "not json",
        ] {
            std::fs::write(project.state_dir().join(FILE), bad).unwrap();
            assert!(read(&project).is_err(), "{bad} was accepted");
        }
    }
}