mockforge_collab/
history.rs

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