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    /// Label **names** assigned to this issue (resolved against
73    /// [`IssueIndex::labels`] at read time, as in JSS: items store names, so a
74    /// recolour needs no cascade). Omitted from the file when empty.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub labels: Vec<String>,
77}
78
79/// The issue index for one repo.
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
81#[serde(rename_all = "camelCase")]
82pub struct IssueIndex {
83    /// Next number to allocate.
84    pub next: u64,
85    /// Repo labels, materialised on first write (Phase 5).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub labels: Option<Vec<Label>>,
88    /// Issues keyed by number.
89    #[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    /// Allocate the next issue number, appending `entry` (its `number`
105    /// field is overwritten with the allocated value). Returns the number.
106    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    /// Issues filtered by state, newest-number first.
115    #[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    /// Count of issues in `state`.
123    #[must_use]
124    pub fn count(&self, state: IssueState) -> usize {
125        self.issues.values().filter(|e| e.state == state).count()
126    }
127
128    /// Mutate an issue's state, returning `true` if it existed.
129    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
140/// Load an issue index (default when absent). A present-but-corrupt file
141/// surfaces an error rather than silently resetting the repo's history.
142pub 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
154/// Persist an issue index atomically (pretty-printed for on-disk
155/// legibility and JSS parity).
156pub 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        // Newest-first ordering.
210        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        // Absent repo → default.
251        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}