solid_pod_rs_forge/spine/
issues.rs1use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ForgeError;
12use crate::spine::SpineStore;
13
14pub const KIND: &str = "issues";
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum IssueState {
21 #[default]
23 Open,
24 Closed,
26 Merged,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub struct Label {
34 pub name: String,
36 pub color: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase")]
44pub struct ThreadPointer {
45 pub author: String,
47 pub resource_url: String,
49 pub at: u64,
51 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53 pub hosted: bool,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "camelCase")]
59pub struct IssueEntry {
60 pub number: u64,
62 pub title: String,
64 pub state: IssueState,
66 pub author: String,
68 pub created_at: u64,
70 pub thread: Vec<ThreadPointer>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct IssueIndex {
78 pub next: u64,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub labels: Option<Vec<Label>>,
83 #[serde(default)]
85 pub issues: BTreeMap<u64, IssueEntry>,
86}
87
88impl Default for IssueIndex {
89 fn default() -> Self {
90 Self {
91 next: 1,
92 labels: None,
93 issues: BTreeMap::new(),
94 }
95 }
96}
97
98impl IssueIndex {
99 pub fn allocate(&mut self, mut entry: IssueEntry) -> u64 {
102 let n = self.next;
103 entry.number = n;
104 self.issues.insert(n, entry);
105 self.next = n + 1;
106 n
107 }
108
109 #[must_use]
111 pub fn by_state(&self, state: IssueState) -> Vec<&IssueEntry> {
112 let mut v: Vec<&IssueEntry> = self.issues.values().filter(|e| e.state == state).collect();
113 v.sort_by_key(|e| std::cmp::Reverse(e.number));
114 v
115 }
116
117 #[must_use]
119 pub fn count(&self, state: IssueState) -> usize {
120 self.issues.values().filter(|e| e.state == state).count()
121 }
122
123 pub fn set_state(&mut self, number: u64, state: IssueState) -> bool {
125 match self.issues.get_mut(&number) {
126 Some(e) => {
127 e.state = state;
128 true
129 }
130 None => false,
131 }
132 }
133}
134
135pub async fn load_issue_index(
138 store: &dyn SpineStore,
139 owner: &str,
140 repo: &str,
141) -> Result<IssueIndex, ForgeError> {
142 match store.load(KIND, owner, repo).await? {
143 Some(bytes) => serde_json::from_slice(&bytes)
144 .map_err(|e| ForgeError::Backend(format!("corrupt issue index {owner}/{repo}: {e}"))),
145 None => Ok(IssueIndex::default()),
146 }
147}
148
149pub async fn save_issue_index(
152 store: &dyn SpineStore,
153 owner: &str,
154 repo: &str,
155 idx: &IssueIndex,
156) -> Result<(), ForgeError> {
157 let bytes = serde_json::to_vec_pretty(idx)?;
158 store.store(KIND, owner, repo, &bytes).await
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::spine::FsSpineStore;
165 use tempfile::TempDir;
166
167 fn entry(title: &str) -> IssueEntry {
168 IssueEntry {
169 number: 0,
170 title: title.to_string(),
171 state: IssueState::Open,
172 author: "did:nostr:abc".to_string(),
173 created_at: 100,
174 thread: vec![ThreadPointer {
175 author: "did:nostr:abc".to_string(),
176 resource_url: "https://pod/x.jsonld".to_string(),
177 at: 100,
178 hosted: false,
179 }],
180 }
181 }
182
183 #[test]
184 fn allocate_is_sequential_and_sets_number() {
185 let mut idx = IssueIndex::default();
186 assert_eq!(idx.allocate(entry("first")), 1);
187 assert_eq!(idx.allocate(entry("second")), 2);
188 assert_eq!(idx.next, 3);
189 assert_eq!(idx.issues.get(&1).unwrap().number, 1);
190 assert_eq!(idx.issues.get(&2).unwrap().title, "second");
191 }
192
193 #[test]
194 fn by_state_and_count() {
195 let mut idx = IssueIndex::default();
196 idx.allocate(entry("a"));
197 idx.allocate(entry("b"));
198 idx.allocate(entry("c"));
199 assert!(idx.set_state(2, IssueState::Closed));
200 assert!(!idx.set_state(99, IssueState::Closed));
201 assert_eq!(idx.count(IssueState::Open), 2);
202 assert_eq!(idx.count(IssueState::Closed), 1);
203 let open = idx.by_state(IssueState::Open);
205 assert_eq!(open[0].number, 3);
206 assert_eq!(open[1].number, 1);
207 }
208
209 #[test]
210 fn state_serialises_camel_case() {
211 let j = serde_json::to_string(&IssueState::Merged).unwrap();
212 assert_eq!(j, "\"merged\"");
213 let s: IssueState = serde_json::from_str("\"closed\"").unwrap();
214 assert_eq!(s, IssueState::Closed);
215 }
216
217 #[test]
218 fn thread_pointer_hosted_flag_omitted_when_false() {
219 let p = ThreadPointer {
220 author: "a".into(),
221 resource_url: "u".into(),
222 at: 1,
223 hosted: false,
224 };
225 let j = serde_json::to_string(&p).unwrap();
226 assert!(!j.contains("hosted"), "false hosted must be omitted: {j}");
227 let p2 = ThreadPointer { hosted: true, ..p };
228 let j2 = serde_json::to_string(&p2).unwrap();
229 assert!(j2.contains("\"hosted\":true"));
230 }
231
232 #[tokio::test]
233 async fn persist_roundtrip_via_store() {
234 let td = TempDir::new().unwrap();
235 let store = FsSpineStore::new(td.path());
236 let mut idx = IssueIndex::default();
237 idx.allocate(entry("hello"));
238 save_issue_index(&store, "alice", "demo", &idx)
239 .await
240 .unwrap();
241
242 let loaded = load_issue_index(&store, "alice", "demo").await.unwrap();
243 assert_eq!(loaded, idx);
244 let empty = load_issue_index(&store, "alice", "other").await.unwrap();
246 assert_eq!(empty, IssueIndex::default());
247 }
248
249 #[tokio::test]
250 async fn corrupt_index_surfaces_error() {
251 let td = TempDir::new().unwrap();
252 let store = FsSpineStore::new(td.path());
253 store.store(KIND, "a", "r", b"not json{").await.unwrap();
254 let res = load_issue_index(&store, "a", "r").await;
255 assert!(matches!(res, Err(ForgeError::Backend(_))));
256 }
257}