Skip to main content

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    ///
113    /// # Errors
114    ///
115    /// Returns an error if the database insert fails.
116    #[allow(clippy::too_many_arguments)]
117    pub async fn create_commit(
118        &self,
119        workspace_id: Uuid,
120        author_id: Uuid,
121        message: String,
122        parent_id: Option<Uuid>,
123        version: i64,
124        snapshot: serde_json::Value,
125        changes: serde_json::Value,
126    ) -> Result<Commit> {
127        let commit =
128            Commit::new(workspace_id, author_id, message, parent_id, version, snapshot, changes);
129
130        sqlx::query!(
131            r#"
132            INSERT INTO commits (id, workspace_id, author_id, message, parent_id, version, snapshot, changes, created_at)
133            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
134            "#,
135            commit.id,
136            commit.workspace_id,
137            commit.author_id,
138            commit.message,
139            commit.parent_id,
140            commit.version,
141            commit.snapshot,
142            commit.changes,
143            commit.created_at
144        )
145        .execute(&self.db)
146        .await?;
147
148        Ok(commit)
149    }
150
151    /// Get a commit by ID
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the commit is not found or the database query fails.
156    pub async fn get_commit(&self, commit_id: Uuid) -> Result<Commit> {
157        sqlx::query_as!(
158            Commit,
159            r#"
160            SELECT
161                id as "id: Uuid",
162                workspace_id as "workspace_id: Uuid",
163                author_id as "author_id: Uuid",
164                message,
165                parent_id as "parent_id: Uuid",
166                version,
167                snapshot as "snapshot: serde_json::Value",
168                changes as "changes: serde_json::Value",
169                created_at as "created_at: chrono::DateTime<chrono::Utc>"
170            FROM commits
171            WHERE id = ?
172            "#,
173            commit_id
174        )
175        .fetch_optional(&self.db)
176        .await?
177        .ok_or_else(|| CollabError::Internal(format!("Commit not found: {commit_id}")))
178    }
179
180    /// Get commit history for a workspace
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the database query fails.
185    pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
186        let limit = limit.unwrap_or(100);
187
188        let commits = sqlx::query_as!(
189            Commit,
190            r#"
191            SELECT
192                id as "id: Uuid",
193                workspace_id as "workspace_id: Uuid",
194                author_id as "author_id: Uuid",
195                message,
196                parent_id as "parent_id: Uuid",
197                version,
198                snapshot as "snapshot: serde_json::Value",
199                changes as "changes: serde_json::Value",
200                created_at as "created_at: chrono::DateTime<chrono::Utc>"
201            FROM commits
202            WHERE workspace_id = ?
203            ORDER BY created_at DESC
204            LIMIT ?
205            "#,
206            workspace_id,
207            limit
208        )
209        .fetch_all(&self.db)
210        .await?;
211
212        Ok(commits)
213    }
214
215    /// Get the latest commit for a workspace
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the database query fails.
220    pub async fn get_latest_commit(&self, workspace_id: Uuid) -> Result<Option<Commit>> {
221        let commit = sqlx::query_as!(
222            Commit,
223            r#"
224            SELECT
225                id as "id: Uuid",
226                workspace_id as "workspace_id: Uuid",
227                author_id as "author_id: Uuid",
228                message,
229                parent_id as "parent_id: Uuid",
230                version,
231                snapshot as "snapshot: serde_json::Value",
232                changes as "changes: serde_json::Value",
233                created_at as "created_at: chrono::DateTime<chrono::Utc>"
234            FROM commits
235            WHERE workspace_id = ?
236            ORDER BY created_at DESC
237            LIMIT 1
238            "#,
239            workspace_id
240        )
241        .fetch_optional(&self.db)
242        .await?;
243
244        Ok(commit)
245    }
246
247    /// Create a named snapshot
248    ///
249    /// # Errors
250    ///
251    /// Returns an error if the commit does not exist or the database insert fails.
252    pub async fn create_snapshot(
253        &self,
254        workspace_id: Uuid,
255        name: String,
256        description: Option<String>,
257        commit_id: Uuid,
258        created_by: Uuid,
259    ) -> Result<Snapshot> {
260        // Verify commit exists
261        self.get_commit(commit_id).await?;
262
263        let snapshot = Snapshot::new(workspace_id, name, description, commit_id, created_by);
264
265        sqlx::query!(
266            r#"
267            INSERT INTO snapshots (id, workspace_id, name, description, commit_id, created_by, created_at)
268            VALUES (?, ?, ?, ?, ?, ?, ?)
269            "#,
270            snapshot.id,
271            snapshot.workspace_id,
272            snapshot.name,
273            snapshot.description,
274            snapshot.commit_id,
275            snapshot.created_by,
276            snapshot.created_at
277        )
278        .execute(&self.db)
279        .await?;
280
281        Ok(snapshot)
282    }
283
284    /// Get a snapshot by name
285    ///
286    /// # Errors
287    ///
288    /// Returns an error if the snapshot is not found or the database query fails.
289    pub async fn get_snapshot(&self, workspace_id: Uuid, name: &str) -> Result<Snapshot> {
290        sqlx::query_as!(
291            Snapshot,
292            r#"
293            SELECT
294                id as "id: Uuid",
295                workspace_id as "workspace_id: Uuid",
296                name,
297                description,
298                commit_id as "commit_id: Uuid",
299                created_by as "created_by: Uuid",
300                created_at as "created_at: chrono::DateTime<chrono::Utc>"
301            FROM snapshots
302            WHERE workspace_id = ? AND name = ?
303            "#,
304            workspace_id,
305            name
306        )
307        .fetch_optional(&self.db)
308        .await?
309        .ok_or_else(|| CollabError::Internal(format!("Snapshot not found: {name}")))
310    }
311
312    /// List all snapshots for a workspace
313    ///
314    /// # Errors
315    ///
316    /// Returns an error if the database query fails.
317    pub async fn list_snapshots(&self, workspace_id: Uuid) -> Result<Vec<Snapshot>> {
318        let snapshots = sqlx::query_as!(
319            Snapshot,
320            r#"
321            SELECT
322                id as "id: Uuid",
323                workspace_id as "workspace_id: Uuid",
324                name,
325                description,
326                commit_id as "commit_id: Uuid",
327                created_by as "created_by: Uuid",
328                created_at as "created_at: chrono::DateTime<chrono::Utc>"
329            FROM snapshots
330            WHERE workspace_id = ?
331            ORDER BY created_at DESC
332            "#,
333            workspace_id
334        )
335        .fetch_all(&self.db)
336        .await?;
337
338        Ok(snapshots)
339    }
340
341    /// Restore workspace to a specific commit
342    ///
343    /// # Errors
344    ///
345    /// Returns an error if the commit is not found or does not belong to the workspace.
346    pub async fn restore_to_commit(
347        &self,
348        workspace_id: Uuid,
349        commit_id: Uuid,
350    ) -> Result<serde_json::Value> {
351        let commit = self.get_commit(commit_id).await?;
352
353        if commit.workspace_id != workspace_id {
354            return Err(CollabError::InvalidInput(
355                "Commit does not belong to this workspace".to_string(),
356            ));
357        }
358
359        Ok(commit.snapshot)
360    }
361
362    /// Compare two commits
363    ///
364    /// # Errors
365    ///
366    /// Returns an error if either commit is not found.
367    pub async fn diff(&self, from_commit: Uuid, to_commit: Uuid) -> Result<serde_json::Value> {
368        let from = self.get_commit(from_commit).await?;
369        let to = self.get_commit(to_commit).await?;
370
371        // Simple diff - in production, use a proper diffing library
372        let diff = serde_json::json!({
373            "from": from.snapshot,
374            "to": to.snapshot,
375            "changes": to.changes
376        });
377
378        Ok(diff)
379    }
380}
381
382/// History tracking with auto-commit
383pub struct History {
384    version_control: VersionControl,
385    auto_commit: bool,
386}
387
388impl History {
389    /// Create a new history tracker
390    #[must_use]
391    pub const fn new(db: Pool<Sqlite>) -> Self {
392        Self {
393            version_control: VersionControl::new(db),
394            auto_commit: true,
395        }
396    }
397
398    /// Enable/disable auto-commit
399    pub const fn set_auto_commit(&mut self, enabled: bool) {
400        self.auto_commit = enabled;
401    }
402
403    /// Track a change (auto-commit if enabled)
404    ///
405    /// # Errors
406    ///
407    /// Returns an error if the commit cannot be created.
408    pub async fn track_change(
409        &self,
410        workspace_id: Uuid,
411        user_id: Uuid,
412        message: String,
413        new_state: serde_json::Value,
414        changes: serde_json::Value,
415    ) -> Result<Option<Commit>> {
416        if !self.auto_commit {
417            return Ok(None);
418        }
419
420        let latest = self.version_control.get_latest_commit(workspace_id).await?;
421        let parent_id = latest.as_ref().map(|c| c.id);
422        let version = latest.map_or(1, |c| c.version + 1);
423
424        let commit = self
425            .version_control
426            .create_commit(workspace_id, user_id, message, parent_id, version, new_state, changes)
427            .await?;
428
429        Ok(Some(commit))
430    }
431
432    /// Get history
433    ///
434    /// # Errors
435    ///
436    /// Returns an error if the database query fails.
437    pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
438        self.version_control.get_history(workspace_id, limit).await
439    }
440
441    /// Create a snapshot
442    ///
443    /// # Errors
444    ///
445    /// Returns an error if there are no commits or the snapshot cannot be created.
446    pub async fn create_snapshot(
447        &self,
448        workspace_id: Uuid,
449        name: String,
450        description: Option<String>,
451        user_id: Uuid,
452    ) -> Result<Snapshot> {
453        // Get the latest commit
454        let latest = self
455            .version_control
456            .get_latest_commit(workspace_id)
457            .await?
458            .ok_or_else(|| CollabError::Internal("No commits found".to_string()))?;
459
460        self.version_control
461            .create_snapshot(workspace_id, name, description, latest.id, user_id)
462            .await
463    }
464
465    /// Restore from snapshot
466    ///
467    /// # Errors
468    ///
469    /// Returns an error if the snapshot is not found or restoration fails.
470    pub async fn restore_snapshot(
471        &self,
472        workspace_id: Uuid,
473        snapshot_name: &str,
474    ) -> Result<serde_json::Value> {
475        let snapshot = self.version_control.get_snapshot(workspace_id, snapshot_name).await?;
476        self.version_control.restore_to_commit(workspace_id, snapshot.commit_id).await
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn test_commit_creation() {
486        let workspace_id = Uuid::new_v4();
487        let author_id = Uuid::new_v4();
488        let commit = Commit::new(
489            workspace_id,
490            author_id,
491            "Initial commit".to_string(),
492            None,
493            1,
494            serde_json::json!({}),
495            serde_json::json!({}),
496        );
497
498        assert_eq!(commit.workspace_id, workspace_id);
499        assert_eq!(commit.author_id, author_id);
500        assert_eq!(commit.version, 1);
501        assert!(commit.parent_id.is_none());
502    }
503
504    #[test]
505    fn test_snapshot_creation() {
506        let workspace_id = Uuid::new_v4();
507        let commit_id = Uuid::new_v4();
508        let created_by = Uuid::new_v4();
509        let snapshot = Snapshot::new(
510            workspace_id,
511            "v1.0.0".to_string(),
512            Some("First release".to_string()),
513            commit_id,
514            created_by,
515        );
516
517        assert_eq!(snapshot.name, "v1.0.0");
518        assert_eq!(snapshot.workspace_id, workspace_id);
519        assert_eq!(snapshot.commit_id, commit_id);
520    }
521}