Skip to main content

libgrite_core/
export.rs

1use serde::Serialize;
2use crate::error::GriteError;
3use crate::store::{GritStore, IssueFilter};
4use crate::types::event::{Event, EventKind};
5use crate::types::ids::{id_to_hex, EventId};
6use crate::types::issue::IssueSummary;
7
8/// Export metadata
9#[derive(Debug, Serialize)]
10pub struct ExportMeta {
11    pub schema_version: u32,
12    pub generated_ts: u64,
13    #[serde(skip_serializing_if = "Option::is_none")]
14    pub wal_head: Option<String>,
15    pub event_count: usize,
16}
17
18/// JSON export format (from export-format.md)
19#[derive(Debug, Serialize)]
20pub struct JsonExport {
21    pub meta: ExportMeta,
22    pub issues: Vec<IssueSummaryJson>,
23    pub events: Vec<EventJson>,
24}
25
26/// Issue summary for JSON export
27#[derive(Debug, Serialize)]
28pub struct IssueSummaryJson {
29    pub issue_id: String,
30    pub title: String,
31    pub state: String,
32    pub labels: Vec<String>,
33    pub assignees: Vec<String>,
34    pub updated_ts: u64,
35    pub comment_count: usize,
36}
37
38impl From<&IssueSummary> for IssueSummaryJson {
39    fn from(s: &IssueSummary) -> Self {
40        Self {
41            issue_id: id_to_hex(&s.issue_id),
42            title: s.title.clone(),
43            state: format!("{:?}", s.state).to_lowercase(),
44            labels: s.labels.clone(),
45            assignees: s.assignees.clone(),
46            updated_ts: s.updated_ts,
47            comment_count: s.comment_count,
48        }
49    }
50}
51
52/// Event for JSON export
53#[derive(Debug, Serialize)]
54pub struct EventJson {
55    pub event_id: String,
56    pub issue_id: String,
57    pub actor: String,
58    pub ts_unix_ms: u64,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub parent: Option<String>,
61    pub kind: serde_json::Value,
62}
63
64impl From<&Event> for EventJson {
65    fn from(e: &Event) -> Self {
66        Self {
67            event_id: id_to_hex(&e.event_id),
68            issue_id: id_to_hex(&e.issue_id),
69            actor: id_to_hex(&e.actor),
70            ts_unix_ms: e.ts_unix_ms,
71            parent: e.parent.as_ref().map(id_to_hex),
72            kind: event_kind_to_json(&e.kind),
73        }
74    }
75}
76
77fn event_kind_to_json(kind: &EventKind) -> serde_json::Value {
78    match kind {
79        EventKind::IssueCreated { title, body, labels } => {
80            serde_json::json!({
81                "IssueCreated": {
82                    "title": title,
83                    "body": body,
84                    "labels": labels
85                }
86            })
87        }
88        EventKind::IssueUpdated { title, body } => {
89            serde_json::json!({
90                "IssueUpdated": {
91                    "title": title,
92                    "body": body
93                }
94            })
95        }
96        EventKind::CommentAdded { body } => {
97            serde_json::json!({
98                "CommentAdded": {
99                    "body": body
100                }
101            })
102        }
103        EventKind::LabelAdded { label } => {
104            serde_json::json!({
105                "LabelAdded": {
106                    "label": label
107                }
108            })
109        }
110        EventKind::LabelRemoved { label } => {
111            serde_json::json!({
112                "LabelRemoved": {
113                    "label": label
114                }
115            })
116        }
117        EventKind::StateChanged { state } => {
118            serde_json::json!({
119                "StateChanged": {
120                    "state": state.as_str()
121                }
122            })
123        }
124        EventKind::LinkAdded { url, note } => {
125            serde_json::json!({
126                "LinkAdded": {
127                    "url": url,
128                    "note": note
129                }
130            })
131        }
132        EventKind::AssigneeAdded { user } => {
133            serde_json::json!({
134                "AssigneeAdded": {
135                    "user": user
136                }
137            })
138        }
139        EventKind::AssigneeRemoved { user } => {
140            serde_json::json!({
141                "AssigneeRemoved": {
142                    "user": user
143                }
144            })
145        }
146        EventKind::AttachmentAdded { name, sha256, mime } => {
147            serde_json::json!({
148                "AttachmentAdded": {
149                    "name": name,
150                    "sha256": id_to_hex(sha256),
151                    "mime": mime
152                }
153            })
154        }
155        EventKind::DependencyAdded { target, dep_type } => {
156            serde_json::json!({
157                "DependencyAdded": {
158                    "target": id_to_hex(target),
159                    "dep_type": dep_type.as_str()
160                }
161            })
162        }
163        EventKind::DependencyRemoved { target, dep_type } => {
164            serde_json::json!({
165                "DependencyRemoved": {
166                    "target": id_to_hex(target),
167                    "dep_type": dep_type.as_str()
168                }
169            })
170        }
171        EventKind::ContextUpdated { path, language, symbols, summary, content_hash } => {
172            serde_json::json!({
173                "ContextUpdated": {
174                    "path": path,
175                    "language": language,
176                    "symbol_count": symbols.len(),
177                    "summary": summary,
178                    "content_hash": id_to_hex(content_hash)
179                }
180            })
181        }
182        EventKind::ProjectContextUpdated { key, value } => {
183            serde_json::json!({
184                "ProjectContextUpdated": {
185                    "key": key,
186                    "value": value
187                }
188            })
189        }
190    }
191}
192
193/// Filter for incremental exports
194pub enum ExportSince {
195    Timestamp(u64),
196    EventId(EventId),
197}
198
199/// Export to JSON format
200pub fn export_json(store: &GritStore, since: Option<ExportSince>) -> Result<JsonExport, GriteError> {
201    let now = std::time::SystemTime::now()
202        .duration_since(std::time::UNIX_EPOCH)
203        .unwrap()
204        .as_millis() as u64;
205
206    // Get all issues
207    let issues: Vec<IssueSummaryJson> = store
208        .list_issues(&IssueFilter::default())?
209        .iter()
210        .map(IssueSummaryJson::from)
211        .collect();
212
213    // Get all events
214    let mut events = store.get_all_events()?;
215
216    // Apply since filter
217    if let Some(since_filter) = since {
218        events = events
219            .into_iter()
220            .filter(|e| match &since_filter {
221                ExportSince::Timestamp(ts) => e.ts_unix_ms > *ts,
222                ExportSince::EventId(event_id) => {
223                    // Include events after the given event_id in sort order
224                    (&e.issue_id, e.ts_unix_ms, &e.actor, &e.event_id)
225                        > (&e.issue_id, e.ts_unix_ms, &e.actor, event_id)
226                }
227            })
228            .collect();
229    }
230
231    let event_jsons: Vec<EventJson> = events.iter().map(EventJson::from).collect();
232
233    Ok(JsonExport {
234        meta: ExportMeta {
235            schema_version: 1,
236            generated_ts: now,
237            wal_head: None, // M1 has no WAL
238            event_count: event_jsons.len(),
239        },
240        issues,
241        events: event_jsons,
242    })
243}
244
245/// Export to Markdown format
246pub fn export_markdown(store: &GritStore, _since: Option<ExportSince>) -> Result<String, GriteError> {
247    let mut md = String::new();
248
249    md.push_str("# Grit Export\n\n");
250
251    let now = std::time::SystemTime::now()
252        .duration_since(std::time::UNIX_EPOCH)
253        .unwrap()
254        .as_millis() as u64;
255    md.push_str(&format!("Generated: {}\n\n", now));
256
257    // List issues
258    let issues = store.list_issues(&IssueFilter::default())?;
259
260    if issues.is_empty() {
261        md.push_str("No issues found.\n");
262        return Ok(md);
263    }
264
265    md.push_str("## Issues\n\n");
266
267    for summary in &issues {
268        let issue_id_hex = id_to_hex(&summary.issue_id);
269        let state_str = format!("{:?}", summary.state).to_lowercase();
270
271        md.push_str(&format!("### {} [{}]\n\n", summary.title, state_str));
272        md.push_str(&format!("**ID:** `{}`\n\n", issue_id_hex));
273
274        if !summary.labels.is_empty() {
275            md.push_str(&format!("**Labels:** {}\n\n", summary.labels.join(", ")));
276        }
277
278        if !summary.assignees.is_empty() {
279            md.push_str(&format!(
280                "**Assignees:** {}\n\n",
281                summary.assignees.join(", ")
282            ));
283        }
284
285        if summary.comment_count > 0 {
286            md.push_str(&format!("**Comments:** {}\n\n", summary.comment_count));
287        }
288
289        // Get full issue for body and comments
290        if let Some(proj) = store.get_issue(&summary.issue_id)? {
291            if !proj.body.is_empty() {
292                md.push_str(&format!("{}\n\n", proj.body));
293            }
294
295            if !proj.comments.is_empty() {
296                md.push_str("#### Comments\n\n");
297                for comment in &proj.comments {
298                    let actor_hex = id_to_hex(&comment.actor);
299                    md.push_str(&format!(
300                        "> **{}** at {}:\n> {}\n\n",
301                        &actor_hex[..8],
302                        comment.ts_unix_ms,
303                        comment.body
304                    ));
305                }
306            }
307        }
308
309        md.push_str("---\n\n");
310    }
311
312    Ok(md)
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use crate::hash::compute_event_id;
319    use crate::types::ids::generate_issue_id;
320    use tempfile::tempdir;
321
322    #[test]
323    fn test_export_json() {
324        let dir = tempdir().unwrap();
325        let store = GritStore::open(dir.path()).unwrap();
326
327        let issue_id = generate_issue_id();
328        let actor = [1u8; 16];
329        let kind = EventKind::IssueCreated {
330            title: "Test".to_string(),
331            body: "Body".to_string(),
332            labels: vec!["bug".to_string()],
333        };
334        let event_id = compute_event_id(&issue_id, &actor, 1000, None, &kind);
335        let event = Event::new(event_id, issue_id, actor, 1000, None, kind);
336        store.insert_event(&event).unwrap();
337
338        let export = export_json(&store, None).unwrap();
339        assert_eq!(export.meta.schema_version, 1);
340        assert_eq!(export.issues.len(), 1);
341        assert_eq!(export.events.len(), 1);
342        assert_eq!(export.issues[0].title, "Test");
343    }
344
345    #[test]
346    fn test_export_markdown() {
347        let dir = tempdir().unwrap();
348        let store = GritStore::open(dir.path()).unwrap();
349
350        let issue_id = generate_issue_id();
351        let actor = [1u8; 16];
352        let kind = EventKind::IssueCreated {
353            title: "Test Issue".to_string(),
354            body: "This is the body".to_string(),
355            labels: vec!["bug".to_string()],
356        };
357        let event_id = compute_event_id(&issue_id, &actor, 1000, None, &kind);
358        let event = Event::new(event_id, issue_id, actor, 1000, None, kind);
359        store.insert_event(&event).unwrap();
360
361        let md = export_markdown(&store, None).unwrap();
362        assert!(md.contains("# Grit Export"));
363        assert!(md.contains("Test Issue"));
364        assert!(md.contains("bug"));
365    }
366}