mur_core/workflow/
versioning.rs1use anyhow::{Context, Result};
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct WorkflowVersion {
12 pub version: String,
14 pub author: String,
16 pub timestamp: DateTime<Utc>,
18 pub diff: String,
20}
21
22fn versioned_repo_path() -> PathBuf {
24 directories::BaseDirs::new()
25 .map(|d| d.home_dir().join(".mur").join("workflows"))
26 .unwrap_or_else(|| PathBuf::from(".mur/workflows"))
27}
28
29fn ensure_git_repo(repo_path: &Path) -> Result<()> {
31 if !repo_path.exists() {
32 std::fs::create_dir_all(repo_path)
33 .with_context(|| format!("Creating workflows directory {:?}", repo_path))?;
34 }
35
36 let git_dir = repo_path.join(".git");
37 if !git_dir.exists() {
38 let output = Command::new("git")
39 .args(["init"])
40 .current_dir(repo_path)
41 .output()
42 .context("Running git init")?;
43
44 if !output.status.success() {
45 let stderr = String::from_utf8_lossy(&output.stderr);
46 anyhow::bail!("git init failed: {}", stderr);
47 }
48 }
49 Ok(())
50}
51
52pub fn version_workflow(workflow_id: &str, yaml_content: &str) -> Result<WorkflowVersion> {
56 let repo_path = versioned_repo_path();
57 ensure_git_repo(&repo_path)?;
58
59 let file_path = repo_path.join(format!("{}.yaml", workflow_id));
60 std::fs::write(&file_path, yaml_content)
61 .with_context(|| format!("Writing workflow file {:?}", file_path))?;
62
63 let output = Command::new("git")
65 .args(["add", &format!("{}.yaml", workflow_id)])
66 .current_dir(&repo_path)
67 .output()
68 .context("git add")?;
69
70 if !output.status.success() {
71 anyhow::bail!("git add failed: {}", String::from_utf8_lossy(&output.stderr));
72 }
73
74 let status = Command::new("git")
76 .args(["diff", "--cached", "--quiet"])
77 .current_dir(&repo_path)
78 .status()
79 .context("git diff --cached")?;
80
81 if status.success() {
82 anyhow::bail!("No changes to version for workflow '{}'", workflow_id);
84 }
85
86 let message = format!("Update workflow: {}", workflow_id);
88 let output = Command::new("git")
89 .args(["commit", "-m", &message, "--author", "mur-commander <mur@localhost>"])
90 .current_dir(&repo_path)
91 .output()
92 .context("git commit")?;
93
94 if !output.status.success() {
95 anyhow::bail!(
96 "git commit failed: {}",
97 String::from_utf8_lossy(&output.stderr)
98 );
99 }
100
101 let log_output = Command::new("git")
103 .args(["log", "-1", "--format=%H%n%an%n%aI%n%s"])
104 .current_dir(&repo_path)
105 .output()
106 .context("git log")?;
107
108 let log_str = String::from_utf8_lossy(&log_output.stdout);
109 let lines: Vec<&str> = log_str.trim().lines().collect();
110
111 let version = lines.first().unwrap_or(&"unknown").to_string();
112 let author = lines.get(1).unwrap_or(&"unknown").to_string();
113 let timestamp = lines
114 .get(2)
115 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
116 .map(|dt| dt.with_timezone(&Utc))
117 .unwrap_or_else(Utc::now);
118 let diff = lines.get(3).unwrap_or(&"").to_string();
119
120 Ok(WorkflowVersion {
121 version,
122 author,
123 timestamp,
124 diff,
125 })
126}
127
128pub fn list_versions(workflow_id: &str) -> Result<Vec<WorkflowVersion>> {
130 let repo_path = versioned_repo_path();
131 let file_name = format!("{}.yaml", workflow_id);
132
133 if !repo_path.join(".git").exists() {
134 return Ok(vec![]);
135 }
136
137 let output = Command::new("git")
138 .args([
139 "log",
140 "--format=%H%n%an%n%aI%n%s%n---",
141 "--follow",
142 "--",
143 &file_name,
144 ])
145 .current_dir(&repo_path)
146 .output()
147 .context("git log")?;
148
149 if !output.status.success() {
150 return Ok(vec![]);
151 }
152
153 let log_str = String::from_utf8_lossy(&output.stdout);
154 let mut versions = Vec::new();
155
156 for entry in log_str.split("---\n") {
157 let entry = entry.trim();
158 if entry.is_empty() {
159 continue;
160 }
161 let lines: Vec<&str> = entry.lines().collect();
162 if lines.len() < 4 {
163 continue;
164 }
165
166 let version = lines[0].to_string();
167 let author = lines[1].to_string();
168 let timestamp = DateTime::parse_from_rfc3339(lines[2])
169 .map(|dt| dt.with_timezone(&Utc))
170 .unwrap_or_else(|_| Utc::now());
171 let diff = lines[3].to_string();
172
173 versions.push(WorkflowVersion {
174 version,
175 author,
176 timestamp,
177 diff,
178 });
179 }
180
181 Ok(versions)
182}
183
184pub fn restore_version(workflow_id: &str, version: &str) -> Result<String> {
186 let repo_path = versioned_repo_path();
187 let file_name = format!("{}.yaml", workflow_id);
188
189 if !repo_path.join(".git").exists() {
190 anyhow::bail!("No version history found for workflows");
191 }
192
193 let output = Command::new("git")
195 .args(["checkout", version, "--", &file_name])
196 .current_dir(&repo_path)
197 .output()
198 .context("git checkout")?;
199
200 if !output.status.success() {
201 anyhow::bail!(
202 "Failed to restore version '{}': {}",
203 version,
204 String::from_utf8_lossy(&output.stderr)
205 );
206 }
207
208 let content = std::fs::read_to_string(repo_path.join(&file_name))
210 .with_context(|| format!("Reading restored workflow '{}'", file_name))?;
211
212 Ok(content)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn test_versioned_repo_path() {
221 let path = versioned_repo_path();
222 assert!(path.to_string_lossy().contains("workflows"));
223 }
224
225 #[test]
226 fn test_list_versions_no_repo() {
227 let result = list_versions("nonexistent-workflow");
229 assert!(result.is_ok());
230 assert!(result.unwrap().is_empty());
231 }
232
233 #[test]
234 fn test_workflow_version_serialization() {
235 let version = WorkflowVersion {
236 version: "abc123".into(),
237 author: "test-user".into(),
238 timestamp: Utc::now(),
239 diff: "Update workflow: test".into(),
240 };
241
242 let json = serde_json::to_string(&version).unwrap();
243 assert!(json.contains("abc123"));
244 assert!(json.contains("test-user"));
245 }
246
247 #[test]
248 fn test_version_and_list_roundtrip() {
249 let tmp = tempfile::tempdir().unwrap();
250 let repo = tmp.path().join("workflows");
251 std::fs::create_dir_all(&repo).unwrap();
252
253 let output = Command::new("git")
255 .args(["init"])
256 .current_dir(&repo)
257 .output()
258 .unwrap();
259 assert!(output.status.success());
260
261 Command::new("git")
263 .args(["config", "user.email", "test@test.com"])
264 .current_dir(&repo)
265 .output()
266 .unwrap();
267 Command::new("git")
268 .args(["config", "user.name", "Test"])
269 .current_dir(&repo)
270 .output()
271 .unwrap();
272
273 let file = repo.join("test-wf.yaml");
275 std::fs::write(&file, "id: test-wf\nname: Test\nsteps: []\n").unwrap();
276 Command::new("git")
277 .args(["add", "test-wf.yaml"])
278 .current_dir(&repo)
279 .output()
280 .unwrap();
281 Command::new("git")
282 .args(["commit", "-m", "Initial"])
283 .current_dir(&repo)
284 .output()
285 .unwrap();
286
287 let output = Command::new("git")
289 .args(["log", "--format=%H%n%an%n%aI%n%s%n---", "--", "test-wf.yaml"])
290 .current_dir(&repo)
291 .output()
292 .unwrap();
293 let log_str = String::from_utf8_lossy(&output.stdout);
294 assert!(log_str.contains("Initial"));
295 }
296}