tokio_prompt_orchestrator/
prompt_versioning.rs1#[derive(Debug, Clone, PartialEq)]
9pub struct PromptVersion {
10 pub version: u32,
12 pub content: String,
14 pub author: String,
16 pub created_at: u64,
18 pub message: String,
20 pub parent_version: Option<u32>,
22}
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct PromptDiff {
27 pub added_lines: Vec<String>,
29 pub removed_lines: Vec<String>,
31 pub unchanged_lines: usize,
33}
34
35impl PromptDiff {
36 pub fn is_empty(&self) -> bool {
38 self.added_lines.is_empty() && self.removed_lines.is_empty()
39 }
40
41 pub fn summary(&self) -> String {
43 format!(
44 "+{} -{} ~{}",
45 self.added_lines.len(),
46 self.removed_lines.len(),
47 self.unchanged_lines
48 )
49 }
50}
51
52pub fn diff_prompts(a: &str, b: &str) -> PromptDiff {
56 let a_lines: Vec<&str> = a.lines().collect();
57 let b_lines: Vec<&str> = b.lines().collect();
58
59 let m = a_lines.len();
60 let n = b_lines.len();
61
62 let mut dp = vec![vec![0usize; n + 1]; m + 1];
64 for i in 1..=m {
65 for j in 1..=n {
66 if a_lines[i - 1] == b_lines[j - 1] {
67 dp[i][j] = dp[i - 1][j - 1] + 1;
68 } else {
69 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
70 }
71 }
72 }
73
74 let mut added = Vec::new();
76 let mut removed = Vec::new();
77 let mut unchanged = 0usize;
78
79 let mut i = m;
80 let mut j = n;
81 while i > 0 || j > 0 {
82 if i > 0 && j > 0 && a_lines[i - 1] == b_lines[j - 1] {
83 unchanged += 1;
84 i -= 1;
85 j -= 1;
86 } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) {
87 added.push(b_lines[j - 1].to_string());
88 j -= 1;
89 } else {
90 removed.push(a_lines[i - 1].to_string());
91 i -= 1;
92 }
93 }
94
95 added.reverse();
97 removed.reverse();
98
99 PromptDiff {
100 added_lines: added,
101 removed_lines: removed,
102 unchanged_lines: unchanged,
103 }
104}
105
106#[derive(Debug, Default)]
108pub struct PromptRepository {
109 versions: Vec<PromptVersion>,
110 next_version: u32,
111}
112
113impl PromptRepository {
114 pub fn new() -> Self {
116 Self {
117 versions: Vec::new(),
118 next_version: 1,
119 }
120 }
121
122 pub fn commit(&mut self, content: &str, author: &str, message: &str) -> u32 {
124 let version = self.next_version;
125 let parent_version = if version > 1 { Some(version - 1) } else { None };
126 self.versions.push(PromptVersion {
127 version,
128 content: content.to_string(),
129 author: author.to_string(),
130 created_at: current_timestamp(),
131 message: message.to_string(),
132 parent_version,
133 });
134 self.next_version += 1;
135 version
136 }
137
138 pub fn checkout(&self, version: u32) -> Option<&PromptVersion> {
140 self.versions
141 .iter()
142 .find(|v| v.version == version)
143 }
144
145 pub fn diff(&self, from: u32, to: u32) -> Option<PromptDiff> {
149 let a = self.checkout(from)?;
150 let b = self.checkout(to)?;
151 Some(diff_prompts(&a.content, &b.content))
152 }
153
154 pub fn log(&self) -> Vec<&PromptVersion> {
156 self.versions.iter().collect()
157 }
158
159 pub fn rollback(&mut self, to_version: u32) -> Option<u32> {
163 let target = self.checkout(to_version)?;
164 let content = target.content.clone();
165 let message = format!("Rollback to version {}", to_version);
166 let new_version = self.commit(&content, "system", &message);
167 Some(new_version)
168 }
169
170 pub fn blame(&self, line: usize) -> Option<&PromptVersion> {
177 let latest = self.versions.last()?;
179 if line >= latest.content.lines().count() {
180 return None;
181 }
182
183 for idx in (0..self.versions.len()).rev() {
185 let version = &self.versions[idx];
186 let lines: Vec<&str> = version.content.lines().collect();
187 if line >= lines.len() {
188 continue;
189 }
190 if idx == 0 {
191 return Some(version);
193 }
194 let parent = &self.versions[idx - 1];
195 let parent_lines: Vec<&str> = parent.content.lines().collect();
196 let parent_line = parent_lines.get(line).copied().unwrap_or("");
197 if lines[line] != parent_line {
198 return Some(version);
199 }
200 }
201 self.versions.first()
203 }
204
205 pub fn history_for_author<'a>(&'a self, author: &str) -> Vec<&'a PromptVersion> {
207 self.versions
208 .iter()
209 .filter(|v| v.author == author)
210 .collect()
211 }
212}
213
214fn current_timestamp() -> u64 {
218 use std::time::{SystemTime, UNIX_EPOCH};
219 SystemTime::now()
220 .duration_since(UNIX_EPOCH)
221 .map(|d| d.as_secs())
222 .unwrap_or(0)
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
232 fn diff_identical_is_empty() {
233 let d = diff_prompts("hello\nworld", "hello\nworld");
234 assert!(d.is_empty());
235 assert_eq!(d.unchanged_lines, 2);
236 }
237
238 #[test]
239 fn diff_empty_strings() {
240 let d = diff_prompts("", "");
241 assert!(d.is_empty());
242 assert_eq!(d.unchanged_lines, 0);
243 }
244
245 #[test]
246 fn diff_all_added() {
247 let d = diff_prompts("", "line1\nline2");
248 assert_eq!(d.added_lines, vec!["line1", "line2"]);
249 assert!(d.removed_lines.is_empty());
250 }
251
252 #[test]
253 fn diff_all_removed() {
254 let d = diff_prompts("line1\nline2", "");
255 assert_eq!(d.removed_lines, vec!["line1", "line2"]);
256 assert!(d.added_lines.is_empty());
257 }
258
259 #[test]
260 fn diff_single_line_change() {
261 let d = diff_prompts("hello\nworld", "hello\nearth");
262 assert_eq!(d.removed_lines, vec!["world"]);
263 assert_eq!(d.added_lines, vec!["earth"]);
264 assert_eq!(d.unchanged_lines, 1);
265 }
266
267 #[test]
268 fn diff_summary_format() {
269 let d = diff_prompts("a\nb\nc", "a\nd\ne\nf");
270 let summary = d.summary();
272 assert!(summary.starts_with('+'));
273 assert!(summary.contains('-'));
274 assert!(summary.contains('~'));
275 }
276
277 #[test]
280 fn commit_returns_sequential_versions() {
281 let mut repo = PromptRepository::new();
282 assert_eq!(repo.commit("v1", "alice", "init"), 1);
283 assert_eq!(repo.commit("v2", "bob", "update"), 2);
284 assert_eq!(repo.commit("v3", "alice", "fix"), 3);
285 }
286
287 #[test]
288 fn checkout_existing_version() {
289 let mut repo = PromptRepository::new();
290 repo.commit("content one", "alice", "first");
291 let v = repo.checkout(1).expect("version 1 must exist");
292 assert_eq!(v.content, "content one");
293 assert_eq!(v.author, "alice");
294 assert_eq!(v.parent_version, None);
295 }
296
297 #[test]
298 fn checkout_missing_version_is_none() {
299 let repo = PromptRepository::new();
300 assert!(repo.checkout(99).is_none());
301 }
302
303 #[test]
304 fn parent_version_set_correctly() {
305 let mut repo = PromptRepository::new();
306 repo.commit("v1", "alice", "init");
307 repo.commit("v2", "alice", "update");
308 assert_eq!(repo.checkout(2).unwrap().parent_version, Some(1));
309 }
310
311 #[test]
312 fn diff_between_versions() {
313 let mut repo = PromptRepository::new();
314 repo.commit("line1\nline2", "alice", "init");
315 repo.commit("line1\nline3", "bob", "change");
316 let d = repo.diff(1, 2).expect("diff must exist");
317 assert_eq!(d.removed_lines, vec!["line2"]);
318 assert_eq!(d.added_lines, vec!["line3"]);
319 }
320
321 #[test]
322 fn diff_missing_version_is_none() {
323 let mut repo = PromptRepository::new();
324 repo.commit("v1", "a", "m");
325 assert!(repo.diff(1, 99).is_none());
326 }
327
328 #[test]
329 fn log_is_chronological() {
330 let mut repo = PromptRepository::new();
331 repo.commit("a", "x", "1");
332 repo.commit("b", "x", "2");
333 repo.commit("c", "x", "3");
334 let log = repo.log();
335 assert_eq!(log.len(), 3);
336 assert_eq!(log[0].version, 1);
337 assert_eq!(log[2].version, 3);
338 }
339
340 #[test]
341 fn rollback_creates_new_commit_with_old_content() {
342 let mut repo = PromptRepository::new();
343 repo.commit("original content", "alice", "init");
344 repo.commit("changed content", "bob", "oops");
345 let new_ver = repo.rollback(1).expect("rollback must succeed");
346 assert_eq!(new_ver, 3);
347 let v = repo.checkout(3).unwrap();
348 assert_eq!(v.content, "original content");
349 assert!(v.message.contains('1'));
350 }
351
352 #[test]
353 fn rollback_missing_version_is_none() {
354 let mut repo = PromptRepository::new();
355 assert!(repo.rollback(42).is_none());
356 }
357
358 #[test]
359 fn blame_initial_commit() {
360 let mut repo = PromptRepository::new();
361 repo.commit("line0\nline1", "alice", "init");
362 let v = repo.blame(0).expect("blame must return a version");
363 assert_eq!(v.version, 1);
364 }
365
366 #[test]
367 fn blame_changed_line_points_to_later_version() {
368 let mut repo = PromptRepository::new();
369 repo.commit("line0\nline1", "alice", "init");
370 repo.commit("line0\nchanged", "bob", "update line1");
371 let v = repo.blame(1).expect("blame must return a version");
372 assert_eq!(v.version, 2);
373 }
374
375 #[test]
376 fn blame_out_of_range_returns_none() {
377 let mut repo = PromptRepository::new();
378 repo.commit("one line", "alice", "init");
379 assert!(repo.blame(999).is_none());
380 }
381
382 #[test]
383 fn history_for_author_filters_correctly() {
384 let mut repo = PromptRepository::new();
385 repo.commit("a", "alice", "1");
386 repo.commit("b", "bob", "2");
387 repo.commit("c", "alice", "3");
388 let alice_history = repo.history_for_author("alice");
389 assert_eq!(alice_history.len(), 2);
390 assert!(alice_history.iter().all(|v| v.author == "alice"));
391 }
392
393 #[test]
394 fn history_for_unknown_author_is_empty() {
395 let mut repo = PromptRepository::new();
396 repo.commit("a", "alice", "1");
397 assert!(repo.history_for_author("nobody").is_empty());
398 }
399}