Skip to main content

solid_pod_rs_forge/spine/
issues.rs

1//! Issue spine: the on-disk pointer index at
2//! `pluginDir/issues/<owner>/<repo>.json`.
3//!
4//! Field names serialise `camelCase` so an existing JSS forge's issue
5//! files parse unchanged (JSS README §Data Model — cited by shape only).
6
7use std::collections::BTreeMap;
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ForgeError;
12use crate::spine::SpineStore;
13
14/// The `issues` spine kind (also the on-disk subdirectory name).
15pub const KIND: &str = "issues";
16
17/// Lifecycle state of an issue or pull.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub enum IssueState {
21    /// Open and active.
22    #[default]
23    Open,
24    /// Closed without merge.
25    Closed,
26    /// Merged (pulls only).
27    Merged,
28}
29
30/// A colour-tagged label. Materialised lazily on first write (Phase 5).
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "camelCase")]
33pub struct Label {
34    /// Label name (unique within a repo).
35    pub name: String,
36    /// Hex colour (`"d73a4a"`), no leading `#`.
37    pub color: String,
38}
39
40/// A pointer into a thread body — a pod resource URL, or a hosted ref for
41/// podless `did:nostr` agents.
42#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
43#[serde(rename_all = "camelCase")]
44pub struct ThreadPointer {
45    /// Author id (WebID or `did:nostr:<hex>`).
46    pub author: String,
47    /// Loopback pod URL, or (when `hosted`) a `pluginDir/hosted/<hex>/<uuid>.json` ref.
48    pub resource_url: String,
49    /// Unix seconds the pointer was recorded.
50    pub at: u64,
51    /// `true` → the body lives in forge-hosted storage, not a pod.
52    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
53    pub hosted: bool,
54}
55
56/// One issue's spine entry (title + state + author + thread pointers).
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "camelCase")]
59pub struct IssueEntry {
60    /// Allocated issue number.
61    pub number: u64,
62    /// Human title (kept in the spine for cheap listing/search).
63    pub title: String,
64    /// Lifecycle state.
65    pub state: IssueState,
66    /// Author id (WebID or `did:nostr:<hex>`).
67    pub author: String,
68    /// Unix seconds of creation.
69    pub created_at: u64,
70    /// Ordered thread pointers (the opening body is index 0).
71    pub thread: Vec<ThreadPointer>,
72}
73
74/// The issue index for one repo.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
76#[serde(rename_all = "camelCase")]
77pub struct IssueIndex {
78    /// Next number to allocate.
79    pub next: u64,
80    /// Repo labels, materialised on first write (Phase 5).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub labels: Option<Vec<Label>>,
83    /// Issues keyed by number.
84    #[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    /// Allocate the next issue number, appending `entry` (its `number`
100    /// field is overwritten with the allocated value). Returns the number.
101    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    /// Issues filtered by state, newest-number first.
110    #[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    /// Count of issues in `state`.
118    #[must_use]
119    pub fn count(&self, state: IssueState) -> usize {
120        self.issues.values().filter(|e| e.state == state).count()
121    }
122
123    /// Mutate an issue's state, returning `true` if it existed.
124    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
135/// Load an issue index (default when absent). A present-but-corrupt file
136/// surfaces an error rather than silently resetting the repo's history.
137pub 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
149/// Persist an issue index atomically (pretty-printed for on-disk
150/// legibility and JSS parity).
151pub 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        // Newest-first ordering.
204        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        // Absent repo → default.
245        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}