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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
76 pub labels: Vec<String>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(rename_all = "camelCase")]
82pub struct IssueIndex {
83 pub next: u64,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub labels: Option<Vec<Label>>,
88 #[serde(default)]
90 pub issues: BTreeMap<u64, IssueEntry>,
91}
92
93impl Default for IssueIndex {
94 fn default() -> Self {
95 Self {
96 next: 1,
97 labels: None,
98 issues: BTreeMap::new(),
99 }
100 }
101}
102
103impl IssueIndex {
104 pub fn allocate(&mut self, mut entry: IssueEntry) -> u64 {
107 let n = self.next;
108 entry.number = n;
109 self.issues.insert(n, entry);
110 self.next = n + 1;
111 n
112 }
113
114 #[must_use]
116 pub fn by_state(&self, state: IssueState) -> Vec<&IssueEntry> {
117 let mut v: Vec<&IssueEntry> = self.issues.values().filter(|e| e.state == state).collect();
118 v.sort_by_key(|e| std::cmp::Reverse(e.number));
119 v
120 }
121
122 #[must_use]
124 pub fn count(&self, state: IssueState) -> usize {
125 self.issues.values().filter(|e| e.state == state).count()
126 }
127
128 pub fn set_state(&mut self, number: u64, state: IssueState) -> bool {
130 match self.issues.get_mut(&number) {
131 Some(e) => {
132 e.state = state;
133 true
134 }
135 None => false,
136 }
137 }
138}
139
140pub async fn load_issue_index(
143 store: &dyn SpineStore,
144 owner: &str,
145 repo: &str,
146) -> Result<IssueIndex, ForgeError> {
147 match store.load(KIND, owner, repo).await? {
148 Some(bytes) => serde_json::from_slice(&bytes)
149 .map_err(|e| ForgeError::Backend(format!("corrupt issue index {owner}/{repo}: {e}"))),
150 None => Ok(IssueIndex::default()),
151 }
152}
153
154pub async fn save_issue_index(
157 store: &dyn SpineStore,
158 owner: &str,
159 repo: &str,
160 idx: &IssueIndex,
161) -> Result<(), ForgeError> {
162 let bytes = serde_json::to_vec_pretty(idx)?;
163 store.store(KIND, owner, repo, &bytes).await
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::spine::FsSpineStore;
170 use tempfile::TempDir;
171
172 fn entry(title: &str) -> IssueEntry {
173 IssueEntry {
174 number: 0,
175 title: title.to_string(),
176 state: IssueState::Open,
177 author: "did:nostr:abc".to_string(),
178 created_at: 100,
179 labels: Vec::new(),
180 thread: vec![ThreadPointer {
181 author: "did:nostr:abc".to_string(),
182 resource_url: "https://pod/x.jsonld".to_string(),
183 at: 100,
184 hosted: false,
185 }],
186 }
187 }
188
189 #[test]
190 fn allocate_is_sequential_and_sets_number() {
191 let mut idx = IssueIndex::default();
192 assert_eq!(idx.allocate(entry("first")), 1);
193 assert_eq!(idx.allocate(entry("second")), 2);
194 assert_eq!(idx.next, 3);
195 assert_eq!(idx.issues.get(&1).unwrap().number, 1);
196 assert_eq!(idx.issues.get(&2).unwrap().title, "second");
197 }
198
199 #[test]
200 fn by_state_and_count() {
201 let mut idx = IssueIndex::default();
202 idx.allocate(entry("a"));
203 idx.allocate(entry("b"));
204 idx.allocate(entry("c"));
205 assert!(idx.set_state(2, IssueState::Closed));
206 assert!(!idx.set_state(99, IssueState::Closed));
207 assert_eq!(idx.count(IssueState::Open), 2);
208 assert_eq!(idx.count(IssueState::Closed), 1);
209 let open = idx.by_state(IssueState::Open);
211 assert_eq!(open[0].number, 3);
212 assert_eq!(open[1].number, 1);
213 }
214
215 #[test]
216 fn state_serialises_camel_case() {
217 let j = serde_json::to_string(&IssueState::Merged).unwrap();
218 assert_eq!(j, "\"merged\"");
219 let s: IssueState = serde_json::from_str("\"closed\"").unwrap();
220 assert_eq!(s, IssueState::Closed);
221 }
222
223 #[test]
224 fn thread_pointer_hosted_flag_omitted_when_false() {
225 let p = ThreadPointer {
226 author: "a".into(),
227 resource_url: "u".into(),
228 at: 1,
229 hosted: false,
230 };
231 let j = serde_json::to_string(&p).unwrap();
232 assert!(!j.contains("hosted"), "false hosted must be omitted: {j}");
233 let p2 = ThreadPointer { hosted: true, ..p };
234 let j2 = serde_json::to_string(&p2).unwrap();
235 assert!(j2.contains("\"hosted\":true"));
236 }
237
238 #[tokio::test]
239 async fn persist_roundtrip_via_store() {
240 let td = TempDir::new().unwrap();
241 let store = FsSpineStore::new(td.path());
242 let mut idx = IssueIndex::default();
243 idx.allocate(entry("hello"));
244 save_issue_index(&store, "alice", "demo", &idx)
245 .await
246 .unwrap();
247
248 let loaded = load_issue_index(&store, "alice", "demo").await.unwrap();
249 assert_eq!(loaded, idx);
250 let empty = load_issue_index(&store, "alice", "other").await.unwrap();
252 assert_eq!(empty, IssueIndex::default());
253 }
254
255 #[tokio::test]
256 async fn corrupt_index_surfaces_error() {
257 let td = TempDir::new().unwrap();
258 let store = FsSpineStore::new(td.path());
259 store.store(KIND, "a", "r", b"not json{").await.unwrap();
260 let res = load_issue_index(&store, "a", "r").await;
261 assert!(matches!(res, Err(ForgeError::Backend(_))));
262 }
263}