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#[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#[derive(Debug, Serialize)]
20pub struct JsonExport {
21 pub meta: ExportMeta,
22 pub issues: Vec<IssueSummaryJson>,
23 pub events: Vec<EventJson>,
24}
25
26#[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#[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
193pub enum ExportSince {
195 Timestamp(u64),
196 EventId(EventId),
197}
198
199pub 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 let issues: Vec<IssueSummaryJson> = store
208 .list_issues(&IssueFilter::default())?
209 .iter()
210 .map(IssueSummaryJson::from)
211 .collect();
212
213 let mut events = store.get_all_events()?;
215
216 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 (&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, event_count: event_jsons.len(),
239 },
240 issues,
241 events: event_jsons,
242 })
243}
244
245pub 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 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 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}