mockforge_chaos/
version_control.rs

1//! Version control for orchestrations
2//!
3//! Provides Git-like version control for orchestration configurations with
4//! branching, diffing, and history tracking.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::io::Write;
12use std::path::Path;
13
14/// Version control commit
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Commit {
17    pub id: String,
18    pub parent_id: Option<String>,
19    pub author: String,
20    pub email: String,
21    pub message: String,
22    pub timestamp: DateTime<Utc>,
23    pub content_hash: String,
24    pub metadata: HashMap<String, String>,
25}
26
27/// Version control branch
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Branch {
30    pub name: String,
31    pub head_commit_id: String,
32    pub created_at: DateTime<Utc>,
33    pub created_by: String,
34    pub protected: bool,
35}
36
37/// Diff between two versions
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Diff {
40    pub from_commit: String,
41    pub to_commit: String,
42    pub changes: Vec<DiffChange>,
43    pub stats: DiffStats,
44}
45
46/// Individual change in a diff
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct DiffChange {
49    pub path: String,
50    pub change_type: DiffChangeType,
51    pub old_value: Option<serde_json::Value>,
52    pub new_value: Option<serde_json::Value>,
53}
54
55/// Type of diff change
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[serde(rename_all = "lowercase")]
58pub enum DiffChangeType {
59    Added,
60    Modified,
61    Deleted,
62}
63
64/// Diff statistics
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DiffStats {
67    pub additions: usize,
68    pub deletions: usize,
69    pub modifications: usize,
70}
71
72/// Version control repository
73#[derive(Debug)]
74pub struct VersionControlRepository {
75    orchestration_id: String,
76    storage_path: String,
77    branches: HashMap<String, Branch>,
78    commits: HashMap<String, Commit>,
79    current_branch: String,
80}
81
82impl VersionControlRepository {
83    /// Create a new repository
84    pub fn new(orchestration_id: String, storage_path: String) -> Result<Self, String> {
85        // Create storage directory
86        fs::create_dir_all(&storage_path).map_err(|e| e.to_string())?;
87
88        let mut repo = Self {
89            orchestration_id,
90            storage_path: storage_path.clone(),
91            branches: HashMap::new(),
92            commits: HashMap::new(),
93            current_branch: "main".to_string(),
94        };
95
96        // Create main branch if it doesn't exist
97        if repo.branches.is_empty() {
98            let initial_commit = Commit {
99                id: Self::generate_commit_id("initial", ""),
100                parent_id: None,
101                author: "System".to_string(),
102                email: "system@mockforge".to_string(),
103                message: "Initial commit".to_string(),
104                timestamp: Utc::now(),
105                content_hash: "".to_string(),
106                metadata: HashMap::new(),
107            };
108
109            let main_branch = Branch {
110                name: "main".to_string(),
111                head_commit_id: initial_commit.id.clone(),
112                created_at: Utc::now(),
113                created_by: "System".to_string(),
114                protected: true,
115            };
116
117            repo.commits.insert(initial_commit.id.clone(), initial_commit);
118            repo.branches.insert("main".to_string(), main_branch);
119        }
120
121        // Save repository state
122        repo.save()?;
123
124        Ok(repo)
125    }
126
127    /// Load repository from disk
128    pub fn load(orchestration_id: String, storage_path: String) -> Result<Self, String> {
129        let repo_file = Path::new(&storage_path).join("repository.json");
130
131        if !repo_file.exists() {
132            return Self::new(orchestration_id, storage_path);
133        }
134
135        let content = fs::read_to_string(&repo_file).map_err(|e| e.to_string())?;
136        let repo: Self = serde_json::from_str(&content).map_err(|e| e.to_string())?;
137
138        Ok(repo)
139    }
140
141    /// Save repository to disk
142    fn save(&self) -> Result<(), String> {
143        let repo_file = Path::new(&self.storage_path).join("repository.json");
144        let content = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
145        let mut file = fs::File::create(repo_file).map_err(|e| e.to_string())?;
146        file.write_all(content.as_bytes()).map_err(|e| e.to_string())?;
147        Ok(())
148    }
149
150    /// Create a commit
151    pub fn commit(
152        &mut self,
153        author: String,
154        email: String,
155        message: String,
156        content: &serde_json::Value,
157    ) -> Result<Commit, String> {
158        let content_hash = Self::hash_content(content);
159        let parent_id = self.get_current_head()?;
160
161        let commit = Commit {
162            id: Self::generate_commit_id(&author, &message),
163            parent_id: Some(parent_id),
164            author,
165            email,
166            message,
167            timestamp: Utc::now(),
168            content_hash: content_hash.clone(),
169            metadata: HashMap::new(),
170        };
171
172        // Save content to disk
173        let content_file = Path::new(&self.storage_path)
174            .join("contents")
175            .join(format!("{}.json", content_hash));
176
177        let parent =
178            content_file.parent().ok_or_else(|| "Invalid content file path".to_string())?;
179        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
180        let content_str = serde_json::to_string_pretty(content).map_err(|e| e.to_string())?;
181        let mut file = fs::File::create(content_file).map_err(|e| e.to_string())?;
182        file.write_all(content_str.as_bytes()).map_err(|e| e.to_string())?;
183
184        // Update branch head
185        if let Some(branch) = self.branches.get_mut(&self.current_branch) {
186            branch.head_commit_id = commit.id.clone();
187        }
188
189        self.commits.insert(commit.id.clone(), commit.clone());
190        self.save()?;
191
192        Ok(commit)
193    }
194
195    /// Create a new branch
196    pub fn create_branch(
197        &mut self,
198        name: String,
199        from_commit: Option<String>,
200    ) -> Result<Branch, String> {
201        if self.branches.contains_key(&name) {
202            return Err(format!("Branch '{}' already exists", name));
203        }
204
205        let head_commit_id = match from_commit {
206            Some(commit_id) => commit_id,
207            None => self.get_current_head()?,
208        };
209
210        let branch = Branch {
211            name: name.clone(),
212            head_commit_id,
213            created_at: Utc::now(),
214            created_by: "user".to_string(),
215            protected: false,
216        };
217
218        self.branches.insert(name, branch.clone());
219        self.save()?;
220
221        Ok(branch)
222    }
223
224    /// Switch to a branch
225    pub fn checkout(&mut self, branch_name: String) -> Result<(), String> {
226        if !self.branches.contains_key(&branch_name) {
227            return Err(format!("Branch '{}' does not exist", branch_name));
228        }
229
230        self.current_branch = branch_name;
231        self.save()?;
232
233        Ok(())
234    }
235
236    /// Get diff between two commits
237    pub fn diff(&self, from_commit: String, to_commit: String) -> Result<Diff, String> {
238        let from_content = self.get_commit_content(&from_commit)?;
239        let to_content = self.get_commit_content(&to_commit)?;
240
241        let changes = Self::compute_diff(&from_content, &to_content, "");
242
243        let stats = DiffStats {
244            additions: changes.iter().filter(|c| c.change_type == DiffChangeType::Added).count(),
245            deletions: changes.iter().filter(|c| c.change_type == DiffChangeType::Deleted).count(),
246            modifications: changes
247                .iter()
248                .filter(|c| c.change_type == DiffChangeType::Modified)
249                .count(),
250        };
251
252        Ok(Diff {
253            from_commit,
254            to_commit,
255            changes,
256            stats,
257        })
258    }
259
260    /// Get commit history
261    pub fn history(&self, max_count: Option<usize>) -> Result<Vec<Commit>, String> {
262        let mut commits = Vec::new();
263        let mut current_id = Some(self.get_current_head()?);
264
265        let limit = max_count.unwrap_or(usize::MAX);
266
267        while let Some(id) = current_id {
268            if commits.len() >= limit {
269                break;
270            }
271
272            if let Some(commit) = self.commits.get(&id) {
273                commits.push(commit.clone());
274                current_id = commit.parent_id.clone();
275            } else {
276                break;
277            }
278        }
279
280        Ok(commits)
281    }
282
283    /// Get commit content
284    pub fn get_commit_content(&self, commit_id: &str) -> Result<serde_json::Value, String> {
285        let commit = self
286            .commits
287            .get(commit_id)
288            .ok_or_else(|| format!("Commit '{}' not found", commit_id))?;
289
290        let content_file = Path::new(&self.storage_path)
291            .join("contents")
292            .join(format!("{}.json", commit.content_hash));
293
294        let content = fs::read_to_string(&content_file).map_err(|e| e.to_string())?;
295        serde_json::from_str(&content).map_err(|e| e.to_string())
296    }
297
298    /// Get current head commit ID
299    fn get_current_head(&self) -> Result<String, String> {
300        self.branches
301            .get(&self.current_branch)
302            .map(|b| b.head_commit_id.clone())
303            .ok_or_else(|| "Current branch not found".to_string())
304    }
305
306    /// Generate commit ID
307    fn generate_commit_id(author: &str, message: &str) -> String {
308        let data = format!("{}{}{}", author, message, Utc::now().timestamp_millis());
309        let mut hasher = Sha256::new();
310        hasher.update(data.as_bytes());
311        format!("{:x}", hasher.finalize())[..16].to_string()
312    }
313
314    /// Hash content
315    fn hash_content(content: &serde_json::Value) -> String {
316        let content_str = serde_json::to_string(content).unwrap();
317        let mut hasher = Sha256::new();
318        hasher.update(content_str.as_bytes());
319        format!("{:x}", hasher.finalize())[..16].to_string()
320    }
321
322    /// Compute diff between two JSON values
323    fn compute_diff(
324        from: &serde_json::Value,
325        to: &serde_json::Value,
326        path: &str,
327    ) -> Vec<DiffChange> {
328        let mut changes = Vec::new();
329
330        match (from, to) {
331            (serde_json::Value::Object(from_obj), serde_json::Value::Object(to_obj)) => {
332                // Check for additions and modifications
333                for (key, to_value) in to_obj {
334                    let new_path = if path.is_empty() {
335                        key.clone()
336                    } else {
337                        format!("{}.{}", path, key)
338                    };
339
340                    if let Some(from_value) = from_obj.get(key) {
341                        if from_value != to_value {
342                            if from_value.is_object() && to_value.is_object() {
343                                changes.extend(Self::compute_diff(from_value, to_value, &new_path));
344                            } else {
345                                changes.push(DiffChange {
346                                    path: new_path,
347                                    change_type: DiffChangeType::Modified,
348                                    old_value: Some(from_value.clone()),
349                                    new_value: Some(to_value.clone()),
350                                });
351                            }
352                        }
353                    } else {
354                        changes.push(DiffChange {
355                            path: new_path,
356                            change_type: DiffChangeType::Added,
357                            old_value: None,
358                            new_value: Some(to_value.clone()),
359                        });
360                    }
361                }
362
363                // Check for deletions
364                for (key, from_value) in from_obj {
365                    if !to_obj.contains_key(key) {
366                        let new_path = if path.is_empty() {
367                            key.clone()
368                        } else {
369                            format!("{}.{}", path, key)
370                        };
371
372                        changes.push(DiffChange {
373                            path: new_path,
374                            change_type: DiffChangeType::Deleted,
375                            old_value: Some(from_value.clone()),
376                            new_value: None,
377                        });
378                    }
379                }
380            }
381            _ => {
382                if from != to {
383                    changes.push(DiffChange {
384                        path: path.to_string(),
385                        change_type: DiffChangeType::Modified,
386                        old_value: Some(from.clone()),
387                        new_value: Some(to.clone()),
388                    });
389                }
390            }
391        }
392
393        changes
394    }
395
396    /// Get all branches
397    pub fn list_branches(&self) -> Vec<Branch> {
398        self.branches.values().cloned().collect()
399    }
400
401    /// Get current branch name
402    pub fn current_branch(&self) -> &str {
403        &self.current_branch
404    }
405}
406
407impl Serialize for VersionControlRepository {
408    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
409    where
410        S: serde::Serializer,
411    {
412        use serde::ser::SerializeStruct;
413        let mut state = serializer.serialize_struct("VersionControlRepository", 5)?;
414        state.serialize_field("orchestration_id", &self.orchestration_id)?;
415        state.serialize_field("storage_path", &self.storage_path)?;
416        state.serialize_field("branches", &self.branches)?;
417        state.serialize_field("commits", &self.commits)?;
418        state.serialize_field("current_branch", &self.current_branch)?;
419        state.end()
420    }
421}
422
423impl<'de> Deserialize<'de> for VersionControlRepository {
424    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
425    where
426        D: serde::Deserializer<'de>,
427    {
428        #[derive(Deserialize)]
429        struct RepoData {
430            orchestration_id: String,
431            storage_path: String,
432            branches: HashMap<String, Branch>,
433            commits: HashMap<String, Commit>,
434            current_branch: String,
435        }
436
437        let data = RepoData::deserialize(deserializer)?;
438
439        Ok(VersionControlRepository {
440            orchestration_id: data.orchestration_id,
441            storage_path: data.storage_path,
442            branches: data.branches,
443            commits: data.commits,
444            current_branch: data.current_branch,
445        })
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use tempfile::tempdir;
453
454    #[test]
455    fn test_repository_creation() {
456        let temp_dir = tempdir().unwrap();
457        let repo = VersionControlRepository::new(
458            "test-orch".to_string(),
459            temp_dir.path().to_str().unwrap().to_string(),
460        )
461        .unwrap();
462
463        assert_eq!(repo.current_branch(), "main");
464        assert_eq!(repo.list_branches().len(), 1);
465    }
466
467    #[test]
468    fn test_commit() {
469        let temp_dir = tempdir().unwrap();
470        let mut repo = VersionControlRepository::new(
471            "test-orch".to_string(),
472            temp_dir.path().to_str().unwrap().to_string(),
473        )
474        .unwrap();
475
476        let content = serde_json::json!({
477            "name": "Test Orchestration",
478            "steps": []
479        });
480
481        let commit = repo
482            .commit(
483                "Test User".to_string(),
484                "test@example.com".to_string(),
485                "Initial orchestration".to_string(),
486                &content,
487            )
488            .unwrap();
489
490        assert_eq!(commit.author, "Test User");
491        assert_eq!(repo.history(None).unwrap().len(), 2); // initial + new commit
492    }
493
494    #[test]
495    fn test_branching() {
496        let temp_dir = tempdir().unwrap();
497        let mut repo = VersionControlRepository::new(
498            "test-orch".to_string(),
499            temp_dir.path().to_str().unwrap().to_string(),
500        )
501        .unwrap();
502
503        repo.create_branch("feature-1".to_string(), None).unwrap();
504        assert_eq!(repo.list_branches().len(), 2);
505
506        repo.checkout("feature-1".to_string()).unwrap();
507        assert_eq!(repo.current_branch(), "feature-1");
508    }
509
510    #[test]
511    fn test_diff() {
512        let temp_dir = tempdir().unwrap();
513        let mut repo = VersionControlRepository::new(
514            "test-orch".to_string(),
515            temp_dir.path().to_str().unwrap().to_string(),
516        )
517        .unwrap();
518
519        let content1 = serde_json::json!({
520            "name": "Test Orchestration",
521            "steps": []
522        });
523
524        let commit1 = repo
525            .commit(
526                "User".to_string(),
527                "user@example.com".to_string(),
528                "First commit".to_string(),
529                &content1,
530            )
531            .unwrap();
532
533        let content2 = serde_json::json!({
534            "name": "Test Orchestration Updated",
535            "steps": [{"name": "step1"}]
536        });
537
538        let commit2 = repo
539            .commit(
540                "User".to_string(),
541                "user@example.com".to_string(),
542                "Second commit".to_string(),
543                &content2,
544            )
545            .unwrap();
546
547        let diff = repo.diff(commit1.id, commit2.id).unwrap();
548        assert!(diff.stats.modifications > 0 || diff.stats.additions > 0);
549    }
550}