Skip to main content

libgrite_core/
export.rs

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