1use serde::{Deserialize, Serialize};
7
8use crate::model::Plan;
9use crate::storage::sqlite::{Checkpoint, ContextItem, Issue, Memory, Session};
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}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SessionRecord {
36 #[serde(flatten)]
38 pub data: Session,
39 pub content_hash: String,
41 pub exported_at: String,
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct IssueRecord {
48 #[serde(flatten)]
50 pub data: Issue,
51 pub content_hash: String,
53 pub exported_at: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ContextItemRecord {
60 #[serde(flatten)]
62 pub data: ContextItem,
63 pub content_hash: String,
65 pub exported_at: String,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct MemoryRecord {
72 #[serde(flatten)]
74 pub data: Memory,
75 pub content_hash: String,
77 pub exported_at: String,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct CheckpointRecord {
84 #[serde(flatten)]
86 pub data: Checkpoint,
87 pub content_hash: String,
89 pub exported_at: String,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct PlanRecord {
96 #[serde(flatten)]
98 pub data: Plan,
99 pub content_hash: String,
101 pub exported_at: String,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct DeletionRecord {
118 pub entity_type: EntityType,
120 pub entity_id: String,
122 pub project_path: String,
124 pub deleted_at: String,
126 pub deleted_by: String,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133pub enum EntityType {
134 Session,
136 Issue,
138 ContextItem,
140 Memory,
142 Checkpoint,
144 Plan,
146}
147
148impl std::fmt::Display for EntityType {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 match self {
151 Self::Session => write!(f, "session"),
152 Self::Issue => write!(f, "issue"),
153 Self::ContextItem => write!(f, "context_item"),
154 Self::Memory => write!(f, "memory"),
155 Self::Checkpoint => write!(f, "checkpoint"),
156 Self::Plan => write!(f, "plan"),
157 }
158 }
159}
160
161impl std::str::FromStr for EntityType {
162 type Err = String;
163
164 fn from_str(s: &str) -> Result<Self, Self::Err> {
165 match s {
166 "session" => Ok(Self::Session),
167 "issue" => Ok(Self::Issue),
168 "context_item" => Ok(Self::ContextItem),
169 "memory" => Ok(Self::Memory),
170 "checkpoint" => Ok(Self::Checkpoint),
171 "plan" => Ok(Self::Plan),
172 _ => Err(format!("Unknown entity type: {s}")),
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
182pub enum MergeStrategy {
183 #[default]
185 PreferNewer,
186 PreferLocal,
188 PreferExternal,
190}
191
192#[derive(Debug, Default, Clone, Serialize)]
194pub struct ExportStats {
195 pub sessions: usize,
197 pub issues: usize,
199 pub context_items: usize,
201 pub memories: usize,
203 pub checkpoints: usize,
205 pub plans: usize,
207 pub deletions: usize,
209}
210
211impl ExportStats {
212 #[must_use]
214 pub fn total(&self) -> usize {
215 self.sessions + self.issues + self.context_items + self.memories + self.checkpoints + self.plans
216 }
217
218 #[must_use]
220 pub fn total_with_deletions(&self) -> usize {
221 self.total() + self.deletions
222 }
223
224 #[must_use]
226 pub fn is_empty(&self) -> bool {
227 self.total_with_deletions() == 0
228 }
229}
230
231#[derive(Debug, Default, Clone, Serialize)]
233pub struct ImportStats {
234 pub sessions: EntityStats,
236 pub issues: EntityStats,
238 pub context_items: EntityStats,
240 pub memories: EntityStats,
242 pub checkpoints: EntityStats,
244 pub plans: EntityStats,
246}
247
248impl ImportStats {
249 #[must_use]
251 pub fn total_processed(&self) -> usize {
252 self.sessions.total()
253 + self.issues.total()
254 + self.context_items.total()
255 + self.memories.total()
256 + self.checkpoints.total()
257 + self.plans.total()
258 }
259
260 #[must_use]
262 pub fn total_created(&self) -> usize {
263 self.sessions.created
264 + self.issues.created
265 + self.context_items.created
266 + self.memories.created
267 + self.checkpoints.created
268 + self.plans.created
269 }
270
271 #[must_use]
273 pub fn total_updated(&self) -> usize {
274 self.sessions.updated
275 + self.issues.updated
276 + self.context_items.updated
277 + self.memories.updated
278 + self.checkpoints.updated
279 + self.plans.updated
280 }
281}
282
283#[derive(Debug, Default, Clone, Serialize)]
285pub struct EntityStats {
286 pub created: usize,
288 pub updated: usize,
290 pub skipped: usize,
292 pub conflicts: usize,
294}
295
296impl EntityStats {
297 #[must_use]
299 pub fn total(&self) -> usize {
300 self.created + self.updated + self.skipped + self.conflicts
301 }
302}
303
304#[derive(Debug, Clone, Serialize)]
306pub struct SyncStatus {
307 pub dirty_sessions: usize,
309 pub dirty_issues: usize,
311 pub dirty_context_items: usize,
313 pub pending_deletions: usize,
315 pub total_sessions: usize,
317 pub total_issues: usize,
319 pub total_context_items: usize,
321 pub needs_backfill: bool,
323 pub has_export_files: bool,
325 pub export_files: Vec<ExportFileInfo>,
327}
328
329#[derive(Debug, Clone, Serialize)]
331pub struct ExportFileInfo {
332 pub name: String,
334 pub size: u64,
336 pub line_count: usize,
338}
339
340#[derive(Debug, thiserror::Error)]
342pub enum SyncError {
343 #[error("IO error: {0}")]
345 Io(#[from] std::io::Error),
346
347 #[error("JSON error: {0}")]
349 Json(#[from] serde_json::Error),
350
351 #[error("Database error: {0}")]
353 Database(String),
354
355 #[error("No dirty records to export (use --force to export all)")]
357 NothingToExport,
358
359 #[error("JSONL file not found: {0}")]
361 FileNotFound(String),
362
363 #[error("Invalid record at line {line}: {message}")]
365 InvalidRecord {
366 line: usize,
368 message: String,
370 },
371}
372
373impl From<rusqlite::Error> for SyncError {
374 fn from(err: rusqlite::Error) -> Self {
375 Self::Database(err.to_string())
376 }
377}
378
379pub type SyncResult<T> = std::result::Result<T, SyncError>;
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 #[test]
387 fn test_export_stats() {
388 let mut stats = ExportStats::default();
389 assert!(stats.is_empty());
390
391 stats.sessions = 5;
392 stats.issues = 3;
393 assert_eq!(stats.total(), 8);
394 assert!(!stats.is_empty());
395 }
396
397 #[test]
398 fn test_entity_stats() {
399 let stats = EntityStats {
400 created: 10,
401 updated: 5,
402 skipped: 2,
403 conflicts: 1,
404 };
405 assert_eq!(stats.total(), 18);
406 }
407
408 #[test]
409 fn test_merge_strategy_default() {
410 let strategy = MergeStrategy::default();
411 assert_eq!(strategy, MergeStrategy::PreferNewer);
412 }
413}