1use serde::{Deserialize, Serialize};
7
8use crate::model::Plan;
9use crate::storage::sqlite::{Checkpoint, ContextItem, Issue, Memory, Session, TimeEntry};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "type", rename_all = "snake_case")]
18pub enum SyncRecord {
19 Session(SessionRecord),
21 Issue(IssueRecord),
23 ContextItem(ContextItemRecord),
25 Memory(MemoryRecord),
27 Checkpoint(CheckpointRecord),
29 Plan(PlanRecord),
31 TimeEntry(TimeEntryRecord),
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SessionRecord {
38 #[serde(flatten)]
40 pub data: Session,
41 pub content_hash: String,
43 pub exported_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct IssueRecord {
50 #[serde(flatten)]
52 pub data: Issue,
53 pub content_hash: String,
55 pub exported_at: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ContextItemRecord {
62 #[serde(flatten)]
64 pub data: ContextItem,
65 pub content_hash: String,
67 pub exported_at: String,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct MemoryRecord {
74 #[serde(flatten)]
76 pub data: Memory,
77 pub content_hash: String,
79 pub exported_at: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct CheckpointRecord {
86 #[serde(flatten)]
88 pub data: Checkpoint,
89 pub content_hash: String,
91 pub exported_at: String,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct PlanRecord {
98 #[serde(flatten)]
100 pub data: Plan,
101 pub content_hash: String,
103 pub exported_at: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct TimeEntryRecord {
110 #[serde(flatten)]
112 pub data: TimeEntry,
113 pub content_hash: String,
115 pub exported_at: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct DeletionRecord {
132 pub entity_type: EntityType,
134 pub entity_id: String,
136 pub project_path: String,
138 pub deleted_at: String,
140 pub deleted_by: String,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "snake_case")]
147pub enum EntityType {
148 Session,
150 Issue,
152 ContextItem,
154 Memory,
156 Checkpoint,
158 Plan,
160 TimeEntry,
162}
163
164impl std::fmt::Display for EntityType {
165 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166 match self {
167 Self::Session => write!(f, "session"),
168 Self::Issue => write!(f, "issue"),
169 Self::ContextItem => write!(f, "context_item"),
170 Self::Memory => write!(f, "memory"),
171 Self::Checkpoint => write!(f, "checkpoint"),
172 Self::Plan => write!(f, "plan"),
173 Self::TimeEntry => write!(f, "time_entry"),
174 }
175 }
176}
177
178impl std::str::FromStr for EntityType {
179 type Err = String;
180
181 fn from_str(s: &str) -> Result<Self, Self::Err> {
182 match s {
183 "session" => Ok(Self::Session),
184 "issue" => Ok(Self::Issue),
185 "context_item" => Ok(Self::ContextItem),
186 "memory" => Ok(Self::Memory),
187 "checkpoint" => Ok(Self::Checkpoint),
188 "plan" => Ok(Self::Plan),
189 "time_entry" => Ok(Self::TimeEntry),
190 _ => Err(format!("Unknown entity type: {s}")),
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
200pub enum MergeStrategy {
201 #[default]
203 PreferNewer,
204 PreferLocal,
206 PreferExternal,
208}
209
210#[derive(Debug, Default, Clone, Serialize)]
212pub struct ExportStats {
213 pub sessions: usize,
215 pub issues: usize,
217 pub context_items: usize,
219 pub memories: usize,
221 pub checkpoints: usize,
223 pub plans: usize,
225 pub time_entries: usize,
227 pub deletions: usize,
229}
230
231impl ExportStats {
232 #[must_use]
234 pub fn total(&self) -> usize {
235 self.sessions + self.issues + self.context_items + self.memories + self.checkpoints + self.plans + self.time_entries
236 }
237
238 #[must_use]
240 pub fn total_with_deletions(&self) -> usize {
241 self.total() + self.deletions
242 }
243
244 #[must_use]
246 pub fn is_empty(&self) -> bool {
247 self.total_with_deletions() == 0
248 }
249}
250
251#[derive(Debug, Default, Clone, Serialize)]
253pub struct ImportStats {
254 pub sessions: EntityStats,
256 pub issues: EntityStats,
258 pub context_items: EntityStats,
260 pub memories: EntityStats,
262 pub checkpoints: EntityStats,
264 pub plans: EntityStats,
266 pub time_entries: EntityStats,
268}
269
270impl ImportStats {
271 #[must_use]
273 pub fn total_processed(&self) -> usize {
274 self.sessions.total()
275 + self.issues.total()
276 + self.context_items.total()
277 + self.memories.total()
278 + self.checkpoints.total()
279 + self.plans.total()
280 + self.time_entries.total()
281 }
282
283 #[must_use]
285 pub fn total_created(&self) -> usize {
286 self.sessions.created
287 + self.issues.created
288 + self.context_items.created
289 + self.memories.created
290 + self.checkpoints.created
291 + self.plans.created
292 + self.time_entries.created
293 }
294
295 #[must_use]
297 pub fn total_updated(&self) -> usize {
298 self.sessions.updated
299 + self.issues.updated
300 + self.context_items.updated
301 + self.memories.updated
302 + self.checkpoints.updated
303 + self.plans.updated
304 + self.time_entries.updated
305 }
306}
307
308#[derive(Debug, Default, Clone, Serialize)]
310pub struct EntityStats {
311 pub created: usize,
313 pub updated: usize,
315 pub skipped: usize,
317 pub conflicts: usize,
319}
320
321impl EntityStats {
322 #[must_use]
324 pub fn total(&self) -> usize {
325 self.created + self.updated + self.skipped + self.conflicts
326 }
327}
328
329#[derive(Debug, Clone, Serialize)]
331pub struct SyncStatus {
332 pub dirty_sessions: usize,
334 pub dirty_issues: usize,
336 pub dirty_context_items: usize,
338 pub pending_deletions: usize,
340 pub total_sessions: usize,
342 pub total_issues: usize,
344 pub total_context_items: usize,
346 pub needs_backfill: bool,
348 pub has_export_files: bool,
350 pub export_files: Vec<ExportFileInfo>,
352}
353
354#[derive(Debug, Clone, Serialize)]
356pub struct ExportFileInfo {
357 pub name: String,
359 pub size: u64,
361 pub line_count: usize,
363}
364
365#[derive(Debug, thiserror::Error)]
367pub enum SyncError {
368 #[error("IO error: {0}")]
370 Io(#[from] std::io::Error),
371
372 #[error("JSON error: {0}")]
374 Json(#[from] serde_json::Error),
375
376 #[error("Database error: {0}")]
378 Database(String),
379
380 #[error("No dirty records to export (use --force to export all)")]
382 NothingToExport,
383
384 #[error("JSONL file not found: {0}")]
386 FileNotFound(String),
387
388 #[error("Invalid record at line {line}: {message}")]
390 InvalidRecord {
391 line: usize,
393 message: String,
395 },
396}
397
398impl From<rusqlite::Error> for SyncError {
399 fn from(err: rusqlite::Error) -> Self {
400 Self::Database(err.to_string())
401 }
402}
403
404pub type SyncResult<T> = std::result::Result<T, SyncError>;
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_export_stats() {
413 let mut stats = ExportStats::default();
414 assert!(stats.is_empty());
415
416 stats.sessions = 5;
417 stats.issues = 3;
418 assert_eq!(stats.total(), 8);
419 assert!(!stats.is_empty());
420 }
421
422 #[test]
423 fn test_entity_stats() {
424 let stats = EntityStats {
425 created: 10,
426 updated: 5,
427 skipped: 2,
428 conflicts: 1,
429 };
430 assert_eq!(stats.total(), 18);
431 }
432
433 #[test]
434 fn test_merge_strategy_default() {
435 let strategy = MergeStrategy::default();
436 assert_eq!(strategy, MergeStrategy::PreferNewer);
437 }
438}