use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const FILE: &str = "github.json";
#[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)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProjectGitHub {
#[serde(default)]
pub boards: Vec<BoardRef>,
}
fn path_of(project: &wsb_prj::Project) -> PathBuf {
project.state_dir().join(FILE)
}
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)
}
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');
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");
}
}
}