use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::ForgeError;
use crate::spine::SpineStore;
pub const KIND: &str = "issues";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum IssueState {
#[default]
Open,
Closed,
Merged,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Label {
pub name: String,
pub color: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ThreadPointer {
pub author: String,
pub resource_url: String,
pub at: u64,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub hosted: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IssueEntry {
pub number: u64,
pub title: String,
pub state: IssueState,
pub author: String,
pub created_at: u64,
pub thread: Vec<ThreadPointer>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct IssueIndex {
pub next: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub labels: Option<Vec<Label>>,
#[serde(default)]
pub issues: BTreeMap<u64, IssueEntry>,
}
impl Default for IssueIndex {
fn default() -> Self {
Self {
next: 1,
labels: None,
issues: BTreeMap::new(),
}
}
}
impl IssueIndex {
pub fn allocate(&mut self, mut entry: IssueEntry) -> u64 {
let n = self.next;
entry.number = n;
self.issues.insert(n, entry);
self.next = n + 1;
n
}
#[must_use]
pub fn by_state(&self, state: IssueState) -> Vec<&IssueEntry> {
let mut v: Vec<&IssueEntry> = self.issues.values().filter(|e| e.state == state).collect();
v.sort_by_key(|e| std::cmp::Reverse(e.number));
v
}
#[must_use]
pub fn count(&self, state: IssueState) -> usize {
self.issues.values().filter(|e| e.state == state).count()
}
pub fn set_state(&mut self, number: u64, state: IssueState) -> bool {
match self.issues.get_mut(&number) {
Some(e) => {
e.state = state;
true
}
None => false,
}
}
}
pub async fn load_issue_index(
store: &dyn SpineStore,
owner: &str,
repo: &str,
) -> Result<IssueIndex, ForgeError> {
match store.load(KIND, owner, repo).await? {
Some(bytes) => serde_json::from_slice(&bytes)
.map_err(|e| ForgeError::Backend(format!("corrupt issue index {owner}/{repo}: {e}"))),
None => Ok(IssueIndex::default()),
}
}
pub async fn save_issue_index(
store: &dyn SpineStore,
owner: &str,
repo: &str,
idx: &IssueIndex,
) -> Result<(), ForgeError> {
let bytes = serde_json::to_vec_pretty(idx)?;
store.store(KIND, owner, repo, &bytes).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::spine::FsSpineStore;
use tempfile::TempDir;
fn entry(title: &str) -> IssueEntry {
IssueEntry {
number: 0,
title: title.to_string(),
state: IssueState::Open,
author: "did:nostr:abc".to_string(),
created_at: 100,
thread: vec![ThreadPointer {
author: "did:nostr:abc".to_string(),
resource_url: "https://pod/x.jsonld".to_string(),
at: 100,
hosted: false,
}],
}
}
#[test]
fn allocate_is_sequential_and_sets_number() {
let mut idx = IssueIndex::default();
assert_eq!(idx.allocate(entry("first")), 1);
assert_eq!(idx.allocate(entry("second")), 2);
assert_eq!(idx.next, 3);
assert_eq!(idx.issues.get(&1).unwrap().number, 1);
assert_eq!(idx.issues.get(&2).unwrap().title, "second");
}
#[test]
fn by_state_and_count() {
let mut idx = IssueIndex::default();
idx.allocate(entry("a"));
idx.allocate(entry("b"));
idx.allocate(entry("c"));
assert!(idx.set_state(2, IssueState::Closed));
assert!(!idx.set_state(99, IssueState::Closed));
assert_eq!(idx.count(IssueState::Open), 2);
assert_eq!(idx.count(IssueState::Closed), 1);
let open = idx.by_state(IssueState::Open);
assert_eq!(open[0].number, 3);
assert_eq!(open[1].number, 1);
}
#[test]
fn state_serialises_camel_case() {
let j = serde_json::to_string(&IssueState::Merged).unwrap();
assert_eq!(j, "\"merged\"");
let s: IssueState = serde_json::from_str("\"closed\"").unwrap();
assert_eq!(s, IssueState::Closed);
}
#[test]
fn thread_pointer_hosted_flag_omitted_when_false() {
let p = ThreadPointer {
author: "a".into(),
resource_url: "u".into(),
at: 1,
hosted: false,
};
let j = serde_json::to_string(&p).unwrap();
assert!(!j.contains("hosted"), "false hosted must be omitted: {j}");
let p2 = ThreadPointer { hosted: true, ..p };
let j2 = serde_json::to_string(&p2).unwrap();
assert!(j2.contains("\"hosted\":true"));
}
#[tokio::test]
async fn persist_roundtrip_via_store() {
let td = TempDir::new().unwrap();
let store = FsSpineStore::new(td.path());
let mut idx = IssueIndex::default();
idx.allocate(entry("hello"));
save_issue_index(&store, "alice", "demo", &idx)
.await
.unwrap();
let loaded = load_issue_index(&store, "alice", "demo").await.unwrap();
assert_eq!(loaded, idx);
let empty = load_issue_index(&store, "alice", "other").await.unwrap();
assert_eq!(empty, IssueIndex::default());
}
#[tokio::test]
async fn corrupt_index_surfaces_error() {
let td = TempDir::new().unwrap();
let store = FsSpineStore::new(td.path());
store.store(KIND, "a", "r", b"not json{").await.unwrap();
let res = load_issue_index(&store, "a", "r").await;
assert!(matches!(res, Err(ForgeError::Backend(_))));
}
}