use crate::error::{CollabError, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Sqlite};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Commit {
pub id: Uuid,
pub workspace_id: Uuid,
pub author_id: Uuid,
pub message: String,
pub parent_id: Option<Uuid>,
pub version: i64,
pub snapshot: serde_json::Value,
pub changes: serde_json::Value,
pub created_at: chrono::DateTime<Utc>,
}
impl Commit {
#[must_use]
pub fn new(
workspace_id: Uuid,
author_id: Uuid,
message: String,
parent_id: Option<Uuid>,
version: i64,
snapshot: serde_json::Value,
changes: serde_json::Value,
) -> Self {
Self {
id: Uuid::new_v4(),
workspace_id,
author_id,
message,
parent_id,
version,
snapshot,
changes,
created_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Snapshot {
pub id: Uuid,
pub workspace_id: Uuid,
pub name: String,
pub description: Option<String>,
pub commit_id: Uuid,
pub created_by: Uuid,
pub created_at: chrono::DateTime<Utc>,
}
impl Snapshot {
#[must_use]
pub fn new(
workspace_id: Uuid,
name: String,
description: Option<String>,
commit_id: Uuid,
created_by: Uuid,
) -> Self {
Self {
id: Uuid::new_v4(),
workspace_id,
name,
description,
commit_id,
created_by,
created_at: Utc::now(),
}
}
}
pub struct VersionControl {
db: Pool<Sqlite>,
}
impl VersionControl {
#[must_use]
pub const fn new(db: Pool<Sqlite>) -> Self {
Self { db }
}
#[allow(clippy::too_many_arguments)]
pub async fn create_commit(
&self,
workspace_id: Uuid,
author_id: Uuid,
message: String,
parent_id: Option<Uuid>,
version: i64,
snapshot: serde_json::Value,
changes: serde_json::Value,
) -> Result<Commit> {
let commit =
Commit::new(workspace_id, author_id, message, parent_id, version, snapshot, changes);
sqlx::query!(
r#"
INSERT INTO commits (id, workspace_id, author_id, message, parent_id, version, snapshot, changes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
commit.id,
commit.workspace_id,
commit.author_id,
commit.message,
commit.parent_id,
commit.version,
commit.snapshot,
commit.changes,
commit.created_at
)
.execute(&self.db)
.await?;
Ok(commit)
}
pub async fn get_commit(&self, commit_id: Uuid) -> Result<Commit> {
sqlx::query_as!(
Commit,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
author_id as "author_id: Uuid",
message,
parent_id as "parent_id: Uuid",
version,
snapshot as "snapshot: serde_json::Value",
changes as "changes: serde_json::Value",
created_at as "created_at: chrono::DateTime<chrono::Utc>"
FROM commits
WHERE id = ?
"#,
commit_id
)
.fetch_optional(&self.db)
.await?
.ok_or_else(|| CollabError::Internal(format!("Commit not found: {commit_id}")))
}
pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
let limit = limit.unwrap_or(100);
let commits = sqlx::query_as!(
Commit,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
author_id as "author_id: Uuid",
message,
parent_id as "parent_id: Uuid",
version,
snapshot as "snapshot: serde_json::Value",
changes as "changes: serde_json::Value",
created_at as "created_at: chrono::DateTime<chrono::Utc>"
FROM commits
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT ?
"#,
workspace_id,
limit
)
.fetch_all(&self.db)
.await?;
Ok(commits)
}
pub async fn get_latest_commit(&self, workspace_id: Uuid) -> Result<Option<Commit>> {
let commit = sqlx::query_as!(
Commit,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
author_id as "author_id: Uuid",
message,
parent_id as "parent_id: Uuid",
version,
snapshot as "snapshot: serde_json::Value",
changes as "changes: serde_json::Value",
created_at as "created_at: chrono::DateTime<chrono::Utc>"
FROM commits
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT 1
"#,
workspace_id
)
.fetch_optional(&self.db)
.await?;
Ok(commit)
}
pub async fn create_snapshot(
&self,
workspace_id: Uuid,
name: String,
description: Option<String>,
commit_id: Uuid,
created_by: Uuid,
) -> Result<Snapshot> {
self.get_commit(commit_id).await?;
let snapshot = Snapshot::new(workspace_id, name, description, commit_id, created_by);
sqlx::query!(
r#"
INSERT INTO snapshots (id, workspace_id, name, description, commit_id, created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
snapshot.id,
snapshot.workspace_id,
snapshot.name,
snapshot.description,
snapshot.commit_id,
snapshot.created_by,
snapshot.created_at
)
.execute(&self.db)
.await?;
Ok(snapshot)
}
pub async fn get_snapshot(&self, workspace_id: Uuid, name: &str) -> Result<Snapshot> {
sqlx::query_as!(
Snapshot,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
name,
description,
commit_id as "commit_id: Uuid",
created_by as "created_by: Uuid",
created_at as "created_at: chrono::DateTime<chrono::Utc>"
FROM snapshots
WHERE workspace_id = ? AND name = ?
"#,
workspace_id,
name
)
.fetch_optional(&self.db)
.await?
.ok_or_else(|| CollabError::Internal(format!("Snapshot not found: {name}")))
}
pub async fn list_snapshots(&self, workspace_id: Uuid) -> Result<Vec<Snapshot>> {
let snapshots = sqlx::query_as!(
Snapshot,
r#"
SELECT
id as "id: Uuid",
workspace_id as "workspace_id: Uuid",
name,
description,
commit_id as "commit_id: Uuid",
created_by as "created_by: Uuid",
created_at as "created_at: chrono::DateTime<chrono::Utc>"
FROM snapshots
WHERE workspace_id = ?
ORDER BY created_at DESC
"#,
workspace_id
)
.fetch_all(&self.db)
.await?;
Ok(snapshots)
}
pub async fn restore_to_commit(
&self,
workspace_id: Uuid,
commit_id: Uuid,
) -> Result<serde_json::Value> {
let commit = self.get_commit(commit_id).await?;
if commit.workspace_id != workspace_id {
return Err(CollabError::InvalidInput(
"Commit does not belong to this workspace".to_string(),
));
}
Ok(commit.snapshot)
}
pub async fn diff(&self, from_commit: Uuid, to_commit: Uuid) -> Result<serde_json::Value> {
let from = self.get_commit(from_commit).await?;
let to = self.get_commit(to_commit).await?;
let diff = serde_json::json!({
"from": from.snapshot,
"to": to.snapshot,
"changes": to.changes
});
Ok(diff)
}
}
pub struct History {
version_control: VersionControl,
auto_commit: bool,
}
impl History {
#[must_use]
pub const fn new(db: Pool<Sqlite>) -> Self {
Self {
version_control: VersionControl::new(db),
auto_commit: true,
}
}
pub const fn set_auto_commit(&mut self, enabled: bool) {
self.auto_commit = enabled;
}
pub async fn track_change(
&self,
workspace_id: Uuid,
user_id: Uuid,
message: String,
new_state: serde_json::Value,
changes: serde_json::Value,
) -> Result<Option<Commit>> {
if !self.auto_commit {
return Ok(None);
}
let latest = self.version_control.get_latest_commit(workspace_id).await?;
let parent_id = latest.as_ref().map(|c| c.id);
let version = latest.map_or(1, |c| c.version + 1);
let commit = self
.version_control
.create_commit(workspace_id, user_id, message, parent_id, version, new_state, changes)
.await?;
Ok(Some(commit))
}
pub async fn get_history(&self, workspace_id: Uuid, limit: Option<i32>) -> Result<Vec<Commit>> {
self.version_control.get_history(workspace_id, limit).await
}
pub async fn create_snapshot(
&self,
workspace_id: Uuid,
name: String,
description: Option<String>,
user_id: Uuid,
) -> Result<Snapshot> {
let latest = self
.version_control
.get_latest_commit(workspace_id)
.await?
.ok_or_else(|| CollabError::Internal("No commits found".to_string()))?;
self.version_control
.create_snapshot(workspace_id, name, description, latest.id, user_id)
.await
}
pub async fn restore_snapshot(
&self,
workspace_id: Uuid,
snapshot_name: &str,
) -> Result<serde_json::Value> {
let snapshot = self.version_control.get_snapshot(workspace_id, snapshot_name).await?;
self.version_control.restore_to_commit(workspace_id, snapshot.commit_id).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_commit_creation() {
let workspace_id = Uuid::new_v4();
let author_id = Uuid::new_v4();
let commit = Commit::new(
workspace_id,
author_id,
"Initial commit".to_string(),
None,
1,
serde_json::json!({}),
serde_json::json!({}),
);
assert_eq!(commit.workspace_id, workspace_id);
assert_eq!(commit.author_id, author_id);
assert_eq!(commit.version, 1);
assert!(commit.parent_id.is_none());
}
#[test]
fn test_snapshot_creation() {
let workspace_id = Uuid::new_v4();
let commit_id = Uuid::new_v4();
let created_by = Uuid::new_v4();
let snapshot = Snapshot::new(
workspace_id,
"v1.0.0".to_string(),
Some("First release".to_string()),
commit_id,
created_by,
);
assert_eq!(snapshot.name, "v1.0.0");
assert_eq!(snapshot.workspace_id, workspace_id);
assert_eq!(snapshot.commit_id, commit_id);
}
}