1use serde::{Deserialize, Serialize};
7
8use crate::storage::sqlite::{Checkpoint, ContextItem, Issue, Memory, Session};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(tag = "type", rename_all = "snake_case")]
17pub enum SyncRecord {
18 Session(SessionRecord),
20 Issue(IssueRecord),
22 ContextItem(ContextItemRecord),
24 Memory(MemoryRecord),
26 Checkpoint(CheckpointRecord),
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SessionRecord {
33 #[serde(flatten)]
35 pub data: Session,
36 pub content_hash: String,
38 pub exported_at: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct IssueRecord {
45 #[serde(flatten)]
47 pub data: Issue,
48 pub content_hash: String,
50 pub exported_at: String,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ContextItemRecord {
57 #[serde(flatten)]
59 pub data: ContextItem,
60 pub content_hash: String,
62 pub exported_at: String,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MemoryRecord {
69 #[serde(flatten)]
71 pub data: Memory,
72 pub content_hash: String,
74 pub exported_at: String,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct CheckpointRecord {
81 #[serde(flatten)]
83 pub data: Checkpoint,
84 pub content_hash: String,
86 pub exported_at: String,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct DeletionRecord {
103 pub entity_type: EntityType,
105 pub entity_id: String,
107 pub project_path: String,
109 pub deleted_at: String,
111 pub deleted_by: String,
113}
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum EntityType {
119 Session,
121 Issue,
123 ContextItem,
125 Memory,
127 Checkpoint,
129}
130
131impl std::fmt::Display for EntityType {
132 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133 match self {
134 Self::Session => write!(f, "session"),
135 Self::Issue => write!(f, "issue"),
136 Self::ContextItem => write!(f, "context_item"),
137 Self::Memory => write!(f, "memory"),
138 Self::Checkpoint => write!(f, "checkpoint"),
139 }
140 }
141}
142
143impl std::str::FromStr for EntityType {
144 type Err = String;
145
146 fn from_str(s: &str) -> Result<Self, Self::Err> {
147 match s {
148 "session" => Ok(Self::Session),
149 "issue" => Ok(Self::Issue),
150 "context_item" => Ok(Self::ContextItem),
151 "memory" => Ok(Self::Memory),
152 "checkpoint" => Ok(Self::Checkpoint),
153 _ => Err(format!("Unknown entity type: {s}")),
154 }
155 }
156}
157
158#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
163pub enum MergeStrategy {
164 #[default]
166 PreferNewer,
167 PreferLocal,
169 PreferExternal,
171}
172
173#[derive(Debug, Default, Clone, Serialize)]
175pub struct ExportStats {
176 pub sessions: usize,
178 pub issues: usize,
180 pub context_items: usize,
182 pub memories: usize,
184 pub checkpoints: usize,
186 pub deletions: usize,
188}
189
190impl ExportStats {
191 #[must_use]
193 pub fn total(&self) -> usize {
194 self.sessions + self.issues + self.context_items + self.memories + self.checkpoints
195 }
196
197 #[must_use]
199 pub fn total_with_deletions(&self) -> usize {
200 self.total() + self.deletions
201 }
202
203 #[must_use]
205 pub fn is_empty(&self) -> bool {
206 self.total_with_deletions() == 0
207 }
208}
209
210#[derive(Debug, Default, Clone, Serialize)]
212pub struct ImportStats {
213 pub sessions: EntityStats,
215 pub issues: EntityStats,
217 pub context_items: EntityStats,
219 pub memories: EntityStats,
221 pub checkpoints: EntityStats,
223}
224
225impl ImportStats {
226 #[must_use]
228 pub fn total_processed(&self) -> usize {
229 self.sessions.total()
230 + self.issues.total()
231 + self.context_items.total()
232 + self.memories.total()
233 + self.checkpoints.total()
234 }
235
236 #[must_use]
238 pub fn total_created(&self) -> usize {
239 self.sessions.created
240 + self.issues.created
241 + self.context_items.created
242 + self.memories.created
243 + self.checkpoints.created
244 }
245
246 #[must_use]
248 pub fn total_updated(&self) -> usize {
249 self.sessions.updated
250 + self.issues.updated
251 + self.context_items.updated
252 + self.memories.updated
253 + self.checkpoints.updated
254 }
255}
256
257#[derive(Debug, Default, Clone, Serialize)]
259pub struct EntityStats {
260 pub created: usize,
262 pub updated: usize,
264 pub skipped: usize,
266 pub conflicts: usize,
268}
269
270impl EntityStats {
271 #[must_use]
273 pub fn total(&self) -> usize {
274 self.created + self.updated + self.skipped + self.conflicts
275 }
276}
277
278#[derive(Debug, Clone, Serialize)]
280pub struct SyncStatus {
281 pub dirty_sessions: usize,
283 pub dirty_issues: usize,
285 pub dirty_context_items: usize,
287 pub pending_deletions: usize,
289 pub total_sessions: usize,
291 pub total_issues: usize,
293 pub total_context_items: usize,
295 pub needs_backfill: bool,
297 pub has_export_files: bool,
299 pub export_files: Vec<ExportFileInfo>,
301}
302
303#[derive(Debug, Clone, Serialize)]
305pub struct ExportFileInfo {
306 pub name: String,
308 pub size: u64,
310 pub line_count: usize,
312}
313
314#[derive(Debug, thiserror::Error)]
316pub enum SyncError {
317 #[error("IO error: {0}")]
319 Io(#[from] std::io::Error),
320
321 #[error("JSON error: {0}")]
323 Json(#[from] serde_json::Error),
324
325 #[error("Database error: {0}")]
327 Database(String),
328
329 #[error("No dirty records to export (use --force to export all)")]
331 NothingToExport,
332
333 #[error("JSONL file not found: {0}")]
335 FileNotFound(String),
336
337 #[error("Invalid record at line {line}: {message}")]
339 InvalidRecord {
340 line: usize,
342 message: String,
344 },
345}
346
347impl From<rusqlite::Error> for SyncError {
348 fn from(err: rusqlite::Error) -> Self {
349 Self::Database(err.to_string())
350 }
351}
352
353pub type SyncResult<T> = std::result::Result<T, SyncError>;
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn test_export_stats() {
362 let mut stats = ExportStats::default();
363 assert!(stats.is_empty());
364
365 stats.sessions = 5;
366 stats.issues = 3;
367 assert_eq!(stats.total(), 8);
368 assert!(!stats.is_empty());
369 }
370
371 #[test]
372 fn test_entity_stats() {
373 let stats = EntityStats {
374 created: 10,
375 updated: 5,
376 skipped: 2,
377 conflicts: 1,
378 };
379 assert_eq!(stats.total(), 18);
380 }
381
382 #[test]
383 fn test_merge_strategy_default() {
384 let strategy = MergeStrategy::default();
385 assert_eq!(strategy, MergeStrategy::PreferNewer);
386 }
387}