Skip to main content

kanban_persistence/
traits.rs

1use crate::PersistenceResult;
2use async_trait::async_trait;
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use uuid::Uuid;
7
8/// Metadata for persistence operations
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct PersistenceMetadata {
11    /// ID of the instance that performed the save
12    pub instance_id: Uuid,
13    /// When this data was saved
14    pub saved_at: DateTime<Utc>,
15    /// Semver of the kanban that wrote this file. `None` on legacy files
16    /// written before the stamp was introduced.
17    #[serde(default)]
18    pub writer_version: Option<String>,
19    /// Git commit of the kanban that wrote this file. `None` on legacy files
20    /// or builds without a git checkout (`"unknown"` is also possible).
21    #[serde(default)]
22    pub writer_commit: Option<String>,
23    /// Format version of the loaded file as the backend understands it
24    /// (JSON envelope version, SQLite schema_version). `None` when not
25    /// populated by the backend. Display-only — backends still own version
26    /// negotiation.
27    #[serde(default, skip_serializing)]
28    pub format_version: Option<u32>,
29}
30
31impl PersistenceMetadata {
32    pub fn new(instance_id: Uuid) -> Self {
33        Self {
34            instance_id,
35            saved_at: Utc::now(),
36            writer_version: None,
37            writer_commit: None,
38            format_version: None,
39        }
40    }
41
42    /// Stamp the writer identity onto the metadata. Backends call this on the
43    /// save path so the saved file remembers which kanban produced it.
44    pub fn with_writer_stamp(
45        mut self,
46        version: impl Into<String>,
47        commit: impl Into<String>,
48    ) -> Self {
49        self.writer_version = Some(version.into());
50        self.writer_commit = Some(commit.into());
51        self
52    }
53}
54
55/// Point-in-time snapshot of all data that needs to be persisted
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct StoreSnapshot {
58    /// Raw JSON bytes representing all boards, columns, cards, etc.
59    pub data: Vec<u8>,
60    /// Metadata about this snapshot
61    pub metadata: PersistenceMetadata,
62}
63
64/// Events that can be emitted during persistence operations
65#[derive(Debug, Clone)]
66pub enum PersistenceEvent {
67    /// Data was successfully saved
68    Saved(PersistenceMetadata),
69    /// External changes were detected
70    ExternalChangeDetected {
71        path: PathBuf,
72        saved_at: DateTime<Utc>,
73    },
74    /// A conflict occurred (our changes vs external changes)
75    ConflictDetected { reason: String },
76    /// An error occurred during persistence
77    Error(String),
78}
79
80/// Trait for abstract storage operations
81/// Implementations handle different backend storage (file, database, etc.)
82#[async_trait]
83pub trait PersistenceStore: Send + Sync {
84    /// Save a snapshot to the store
85    async fn save(&self, snapshot: StoreSnapshot) -> PersistenceResult<PersistenceMetadata>;
86
87    /// Load the current snapshot from the store
88    async fn load(&self) -> PersistenceResult<(StoreSnapshot, PersistenceMetadata)>;
89
90    /// Check if the store file exists
91    async fn exists(&self) -> bool;
92
93    /// Get the path to the store file
94    fn path(&self) -> &Path;
95
96    /// Get the unique instance ID for this store
97    fn instance_id(&self) -> uuid::Uuid;
98
99    /// Load the store synchronously (no async runtime required).
100    /// Returns `Ok(None)` when the backing file does not exist.
101    ///
102    /// The default implementation returns an error; backends that support
103    /// synchronous loading (e.g. `JsonFileStore`) override this.
104    #[allow(clippy::type_complexity)]
105    fn load_sync(&self) -> PersistenceResult<Option<(StoreSnapshot, PersistenceMetadata)>> {
106        Err(crate::PersistenceError::Unsupported(
107            "load_sync not supported by this backend".into(),
108        ))
109    }
110
111    /// Drain any open connections / file handles before the backing file is
112    /// unlinked. Required on Windows: the OS refuses to delete files that
113    /// still have live handles, and async resources (e.g. an `sqlx` pool)
114    /// outlive synchronous `Drop` because the runtime needs time to close
115    /// each connection.
116    ///
117    /// The default is a no-op; backends with long-lived handles (e.g.
118    /// `SqliteStore`) override this.
119    async fn close(&self) {}
120}
121
122/// Trait for detecting changes to the storage file
123/// Used for multi-instance coordination
124#[async_trait]
125pub trait ChangeDetector: Send + Sync {
126    /// Start watching the file for changes
127    async fn start_watching(&self, path: PathBuf) -> PersistenceResult<()>;
128
129    /// Stop watching the file
130    async fn stop_watching(&self) -> PersistenceResult<()>;
131
132    /// Subscribe to change events
133    /// Returns a broadcast receiver that yields `ChangeEvent` when the file changes
134    fn subscribe(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent>;
135
136    /// Check if currently watching
137    fn is_watching(&self) -> bool;
138}
139
140/// Event indicating a change to the watched file
141#[derive(Debug, Clone)]
142pub struct ChangeEvent {
143    /// Path to the file that changed
144    pub path: PathBuf,
145    /// When the change was detected
146    pub detected_at: DateTime<Utc>,
147}
148
149/// Trait for serialization/deserialization strategies
150/// Allows swapping JSON for binary formats, databases, etc.
151pub trait Serializer<T: Send + Sync>: Send + Sync {
152    /// Serialize data to bytes
153    fn serialize(&self, data: &T) -> PersistenceResult<Vec<u8>>;
154
155    /// Deserialize data from bytes
156    fn deserialize(&self, bytes: &[u8]) -> PersistenceResult<T>;
157}
158
159/// Format versions for migration tracking
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
161pub enum FormatVersion {
162    V1,
163    V2,
164    V3,
165    V4,
166    V5,
167    /// V6 splits the dependency graph from a single edge-type-tagged list
168    /// (`graph.cards.edges`) into three sub-graphs keyed by edge kind
169    /// (`graph.parent_child`, `graph.blocks`, `graph.relates`).
170    V6,
171    /// V7 renames the spawns sub-graph bucket from `parent_child` to
172    /// `spawns` so the wire format matches the `SpawnsEdge` struct,
173    /// the `spawns_edges()` accessor, and the SQLite `spawns_edges`
174    /// table. Pure key rename — edge contents are unchanged.
175    V7,
176}
177
178impl FormatVersion {
179    /// The highest format version this binary can read or produce.
180    pub const MAX: Self = Self::V7;
181
182    pub fn as_u32(self) -> u32 {
183        match self {
184            Self::V1 => 1,
185            Self::V2 => 2,
186            Self::V3 => 3,
187            Self::V4 => 4,
188            Self::V5 => 5,
189            Self::V6 => 6,
190            Self::V7 => 7,
191        }
192    }
193
194    pub fn from_u32(v: u32) -> Option<Self> {
195        match v {
196            1 => Some(Self::V1),
197            2 => Some(Self::V2),
198            3 => Some(Self::V3),
199            4 => Some(Self::V4),
200            5 => Some(Self::V5),
201            6 => Some(Self::V6),
202            7 => Some(Self::V7),
203            _ => None,
204        }
205    }
206}
207
208/// Trait for migration strategies between format versions
209#[async_trait]
210pub trait MigrationStrategy: Send + Sync {
211    /// Detect the version of a file on disk
212    async fn detect_version(&self, path: &Path) -> PersistenceResult<FormatVersion>;
213
214    /// Migrate from one version to another
215    /// Returns the path to the migrated file
216    async fn migrate(
217        &self,
218        from: FormatVersion,
219        to: FormatVersion,
220        path: &Path,
221    ) -> PersistenceResult<PathBuf>;
222}
223
224/// Trait for conflict resolution between local and external changes
225pub trait ConflictResolver: Send + Sync {
226    /// Determine whether local or external change wins
227    /// Returns true if external changes should be used, false for local changes
228    fn should_use_external(
229        &self,
230        local_metadata: &PersistenceMetadata,
231        external_metadata: &PersistenceMetadata,
232    ) -> bool;
233
234    /// Get a human-readable description of the conflict resolution
235    fn explain_resolution(
236        &self,
237        local_metadata: &PersistenceMetadata,
238        external_metadata: &PersistenceMetadata,
239    ) -> String;
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_format_version_max_equals_v7() {
248        assert_eq!(FormatVersion::MAX, FormatVersion::V7);
249    }
250
251    #[test]
252    fn test_format_version_max_as_u32_matches_largest_variant() {
253        assert_eq!(FormatVersion::MAX.as_u32(), 7);
254    }
255
256    #[test]
257    fn test_default_metadata_has_no_writer_stamp() {
258        let md = PersistenceMetadata::new(Uuid::nil());
259        assert!(md.writer_version.is_none());
260        assert!(md.writer_commit.is_none());
261    }
262
263    #[test]
264    fn test_with_writer_stamp_populates_both_fields() {
265        let md = PersistenceMetadata::new(Uuid::nil()).with_writer_stamp("0.6.0", "abc1234");
266        assert_eq!(md.writer_version.as_deref(), Some("0.6.0"));
267        assert_eq!(md.writer_commit.as_deref(), Some("abc1234"));
268    }
269
270    #[test]
271    fn test_metadata_serde_round_trips_with_writer_stamp() {
272        let md = PersistenceMetadata::new(Uuid::nil()).with_writer_stamp("0.6.0", "abc1234");
273        let json = serde_json::to_string(&md).unwrap();
274        let parsed: PersistenceMetadata = serde_json::from_str(&json).unwrap();
275        assert_eq!(parsed.writer_version.as_deref(), Some("0.6.0"));
276        assert_eq!(parsed.writer_commit.as_deref(), Some("abc1234"));
277    }
278
279    #[test]
280    fn test_metadata_deserialize_legacy_envelope_missing_stamp_fields() {
281        // Old files lack writer_version / writer_commit. They must still parse.
282        let legacy = serde_json::json!({
283            "instance_id": "550e8400-e29b-41d4-a716-446655440000",
284            "saved_at": "2024-01-01T00:00:00Z"
285        });
286        let parsed: PersistenceMetadata = serde_json::from_value(legacy).unwrap();
287        assert!(parsed.writer_version.is_none());
288        assert!(parsed.writer_commit.is_none());
289    }
290}