mockforge_collab/
history.rs

1//! Version control and history tracking
2
3use crate::error::{CollabError, Result};
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use sqlx::{Pool, Sqlite};
7use uuid::Uuid;
8
9/// A commit in the history
10#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
11pub struct Commit {
12    /// Unique commit ID
13    pub id: Uuid,
14    /// Workspace ID
15    pub workspace_id: Uuid,
16    /// User who made the commit
17    pub author_id: Uuid,
18    /// Commit message
19    pub message: String,
20    /// Parent commit ID (None for initial commit)
21    pub parent_id: Option<Uuid>,
22    /// Workspace version at this commit
23    pub version: i64,
24    /// Full workspace state snapshot (JSON)
25    pub snapshot: serde_json::Value,
26    /// Changes made in this commit (diff)
27    pub changes: serde_json::Value,
28    /// Timestamp
29    pub created_at: chrono::DateTime<Utc>,
30}
31
32impl Commit {
33    /// Create a new commit
34    #[must_use]
35    pub fn new(
36        workspace_id: Uuid,
37        author_id: Uuid,
38        message: String,
39        parent_id: Option<Uuid>,
40        version: i64,
41        snapshot: serde_json::Value,
42        changes: serde_json::Value,
43    ) -> Self {
44        Self {
45            id: Uuid::new_v4(),
46            workspace_id,
47            author_id,
48            message,
49            parent_id,
50            version,
51            snapshot,
52            changes,
53            created_at: Utc::now(),
54        }
55    }
56}
57
58/// A named snapshot (like a git tag)
59#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
60pub struct Snapshot {
61    /// Unique snapshot ID
62    pub id: Uuid,
63    /// Workspace ID
64    pub workspace_id: Uuid,
65    /// Snapshot name
66    pub name: String,
67    /// Description
68    pub description: Option<String>,
69    /// Commit ID this snapshot points to
70    pub commit_id: Uuid,
71    /// Created by
72    pub created_by: Uuid,
73    /// Created timestamp
74    pub created_at: chrono::DateTime<Utc>,
75}
76
77impl Snapshot {
78    /// Create a new snapshot
79    #[must_use]
80    pub fn new(
81        workspace_id: Uuid,
82        name: String,
83        description: Option<String>,
84        commit_id: Uuid,
85        created_by: Uuid,
86    ) -> Self {
87        Self {
88            id: Uuid::new_v4(),
89            workspace_id,
90            name,
91            description,
92            commit_id,
93            created_by,
94            created_at: Utc::now(),
95        }
96    }
97}
98
99/// Version control system for workspaces
100pub struct VersionControl {
101    db: Pool<Sqlite>,
102}
103
104impl VersionControl {
105    /// Create a new version control system
106    #[must_use]
107    pub const fn new(db: Pool<Sqlite>) -> Self {
108        Self { db }
109    }
110
111    /// Create a commit
112    pub async fn create_commit(
113        &self,
114        workspace_id: Uuid,
115        author_id: Uuid,
116        message: String,
117        parent_id: Option<Uuid>,
118        version: i64,
119        snapshot: serde_json::Value,
120        changes: serde_json::Value,
121    ) -> Result<Commit> {
122        let commit =
123            Commit::new(workspace_id, author_id, message, parent_id, version, snapshot, changes);
124
125        sqlx::query!(
126            r#"
127            INSERT INTO commits (id, workspace_id, author_id, message, parent_id, version, snapshot, changes, created_at)
128            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
129            "#,
130            commit.id,
131            commit.workspace_id,
132            commit.author_id,
133            commit.message,
134            commit.parent_id,
135            commit.version,
136            commit.snapshot,
137            commit.changes,
138            commit.created_at
139        )
140        .execute(&self.db)
141        .await?;
142
143        Ok(commit)
144    }
145
146    /// Get a commit by ID
147    pub async fn get_commit(&self, commit_id: Uuid) -> Result<Commit> {
148        sqlx::query_as!(
149            Commit,
150            r#"
151            SELECT
152                id as "id: Uuid",
153                workspace_id as "workspace_id: Uuid",
154                author_id as "author_id: Uuid",
155                message,
156                parent_id as "parent_id: Uuid",
157                version,
158                snapshot,
159                changes,
160                created_at as "created_at: chrono::DateTime<chrono::Utc>"
161            FROM commits
162            WHERE id = ?
163            "#,
164            commit_id
165        )
166        .fetch_optional(&self.db)
167        .await?
168        .ok_or_else(|| CollabError::Internal(format!("Commit not found: {commit_id}")))
169    }
170
171    /// Get commit history for a workspace
172    pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
173        let limit = limit.unwrap_or(100);
174
175        let commits = sqlx::query_as!(
176            Commit,
177            r#"
178            SELECT
179                id as "id: Uuid",
180                workspace_id as "workspace_id: Uuid",
181                author_id as "author_id: Uuid",
182                message,
183                parent_id as "parent_id: Uuid",
184                version,
185                snapshot,
186                changes,
187                created_at as "created_at: chrono::DateTime<chrono::Utc>"
188            FROM commits
189            WHERE workspace_id = ?
190            ORDER BY created_at DESC
191            LIMIT ?
192            "#,
193            workspace_id,
194            limit
195        )
196        .fetch_all(&self.db)
197        .await?;
198
199        Ok(commits)
200    }
201
202    /// Get the latest commit for a workspace
203    pub async fn get_latest_commit(&self, workspace_id: Uuid) -> Result<Option<Commit>> {
204        let commit = sqlx::query_as!(
205            Commit,
206            r#"
207            SELECT
208                id as "id: Uuid",
209                workspace_id as "workspace_id: Uuid",
210                author_id as "author_id: Uuid",
211                message,
212                parent_id as "parent_id: Uuid",
213                version,
214                snapshot,
215                changes,
216                created_at as "created_at: chrono::DateTime<chrono::Utc>"
217            FROM commits
218            WHERE workspace_id = ?
219            ORDER BY created_at DESC
220            LIMIT 1
221            "#,
222            workspace_id
223        )
224        .fetch_optional(&self.db)
225        .await?;
226
227        Ok(commit)
228    }
229
230    /// Create a named snapshot
231    pub async fn create_snapshot(
232        &self,
233        workspace_id: Uuid,
234        name: String,
235        description: Option<String>,
236        commit_id: Uuid,
237        created_by: Uuid,
238    ) -> Result<Snapshot> {
239        // Verify commit exists
240        self.get_commit(commit_id).await?;
241
242        let snapshot = Snapshot::new(workspace_id, name, description, commit_id, created_by);
243
244        sqlx::query!(
245            r#"
246            INSERT INTO snapshots (id, workspace_id, name, description, commit_id, created_by, created_at)
247            VALUES (?, ?, ?, ?, ?, ?, ?)
248            "#,
249            snapshot.id,
250            snapshot.workspace_id,
251            snapshot.name,
252            snapshot.description,
253            snapshot.commit_id,
254            snapshot.created_by,
255            snapshot.created_at
256        )
257        .execute(&self.db)
258        .await?;
259
260        Ok(snapshot)
261    }
262
263    /// Get a snapshot by name
264    pub async fn get_snapshot(&self, workspace_id: Uuid, name: &str) -> Result<Snapshot> {
265        sqlx::query_as!(
266            Snapshot,
267            r#"
268            SELECT
269                id as "id: Uuid",
270                workspace_id as "workspace_id: Uuid",
271                name,
272                description,
273                commit_id as "commit_id: Uuid",
274                created_by as "created_by: Uuid",
275                created_at as "created_at: chrono::DateTime<chrono::Utc>"
276            FROM snapshots
277            WHERE workspace_id = ? AND name = ?
278            "#,
279            workspace_id,
280            name
281        )
282        .fetch_optional(&self.db)
283        .await?
284        .ok_or_else(|| CollabError::Internal(format!("Snapshot not found: {name}")))
285    }
286
287    /// List all snapshots for a workspace
288    pub async fn list_snapshots(&self, workspace_id: Uuid) -> Result<Vec<Snapshot>> {
289        let snapshots = sqlx::query_as!(
290            Snapshot,
291            r#"
292            SELECT
293                id as "id: Uuid",
294                workspace_id as "workspace_id: Uuid",
295                name,
296                description,
297                commit_id as "commit_id: Uuid",
298                created_by as "created_by: Uuid",
299                created_at as "created_at: chrono::DateTime<chrono::Utc>"
300            FROM snapshots
301            WHERE workspace_id = ?
302            ORDER BY created_at DESC
303            "#,
304            workspace_id
305        )
306        .fetch_all(&self.db)
307        .await?;
308
309        Ok(snapshots)
310    }
311
312    /// Restore workspace to a specific commit
313    pub async fn restore_to_commit(
314        &self,
315        workspace_id: Uuid,
316        commit_id: Uuid,
317    ) -> Result<serde_json::Value> {
318        let commit = self.get_commit(commit_id).await?;
319
320        if commit.workspace_id != workspace_id {
321            return Err(CollabError::InvalidInput(
322                "Commit does not belong to this workspace".to_string(),
323            ));
324        }
325
326        Ok(commit.snapshot)
327    }
328
329    /// Compare two commits
330    pub async fn diff(&self, from_commit: Uuid, to_commit: Uuid) -> Result<serde_json::Value> {
331        let from = self.get_commit(from_commit).await?;
332        let to = self.get_commit(to_commit).await?;
333
334        // Simple diff - in production, use a proper diffing library
335        let diff = serde_json::json!({
336            "from": from.snapshot,
337            "to": to.snapshot,
338            "changes": to.changes
339        });
340
341        Ok(diff)
342    }
343}
344
345/// History tracking with auto-commit
346pub struct History {
347    version_control: VersionControl,
348    auto_commit: bool,
349}
350
351impl History {
352    /// Create a new history tracker
353    #[must_use]
354    pub const fn new(db: Pool<Sqlite>) -> Self {
355        Self {
356            version_control: VersionControl::new(db),
357            auto_commit: true,
358        }
359    }
360
361    /// Enable/disable auto-commit
362    pub const fn set_auto_commit(&mut self, enabled: bool) {
363        self.auto_commit = enabled;
364    }
365
366    /// Track a change (auto-commit if enabled)
367    pub async fn track_change(
368        &self,
369        workspace_id: Uuid,
370        user_id: Uuid,
371        message: String,
372        new_state: serde_json::Value,
373        changes: serde_json::Value,
374    ) -> Result<Option<Commit>> {
375        if !self.auto_commit {
376            return Ok(None);
377        }
378
379        let latest = self.version_control.get_latest_commit(workspace_id).await?;
380        let parent_id = latest.as_ref().map(|c| c.id);
381        let version = latest.map_or(1, |c| c.version + 1);
382
383        let commit = self
384            .version_control
385            .create_commit(workspace_id, user_id, message, parent_id, version, new_state, changes)
386            .await?;
387
388        Ok(Some(commit))
389    }
390
391    /// Get history
392    pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
393        self.version_control.get_history(workspace_id, limit).await
394    }
395
396    /// Create a snapshot
397    pub async fn create_snapshot(
398        &self,
399        workspace_id: Uuid,
400        name: String,
401        description: Option<String>,
402        user_id: Uuid,
403    ) -> Result<Snapshot> {
404        // Get the latest commit
405        let latest = self
406            .version_control
407            .get_latest_commit(workspace_id)
408            .await?
409            .ok_or_else(|| CollabError::Internal("No commits found".to_string()))?;
410
411        self.version_control
412            .create_snapshot(workspace_id, name, description, latest.id, user_id)
413            .await
414    }
415
416    /// Restore from snapshot
417    pub async fn restore_snapshot(
418        &self,
419        workspace_id: Uuid,
420        snapshot_name: &str,
421    ) -> Result<serde_json::Value> {
422        let snapshot = self.version_control.get_snapshot(workspace_id, snapshot_name).await?;
423        self.version_control.restore_to_commit(workspace_id, snapshot.commit_id).await
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430
431    #[test]
432    fn test_commit_creation() {
433        let workspace_id = Uuid::new_v4();
434        let author_id = Uuid::new_v4();
435        let commit = Commit::new(
436            workspace_id,
437            author_id,
438            "Initial commit".to_string(),
439            None,
440            1,
441            serde_json::json!({}),
442            serde_json::json!({}),
443        );
444
445        assert_eq!(commit.workspace_id, workspace_id);
446        assert_eq!(commit.author_id, author_id);
447        assert_eq!(commit.version, 1);
448        assert!(commit.parent_id.is_none());
449    }
450
451    #[test]
452    fn test_snapshot_creation() {
453        let workspace_id = Uuid::new_v4();
454        let commit_id = Uuid::new_v4();
455        let created_by = Uuid::new_v4();
456        let snapshot = Snapshot::new(
457            workspace_id,
458            "v1.0.0".to_string(),
459            Some("First release".to_string()),
460            commit_id,
461            created_by,
462        );
463
464        assert_eq!(snapshot.name, "v1.0.0");
465        assert_eq!(snapshot.workspace_id, workspace_id);
466        assert_eq!(snapshot.commit_id, commit_id);
467    }
468}