Skip to main content

tokio_prompt_orchestrator/
prompt_versioning.rs

1//! Prompt version control with diff and rollback.
2//!
3//! Provides [`PromptRepository`] for committing, checking out, diffing, and
4//! rolling back versioned prompt content using a line-by-line LCS diff
5//! (Wagner-Fischer algorithm).
6
7/// A single committed version of a prompt.
8#[derive(Debug, Clone, PartialEq)]
9pub struct PromptVersion {
10    /// Monotonically increasing version number starting at 1.
11    pub version: u32,
12    /// Full prompt content at this version.
13    pub content: String,
14    /// Author identifier (name or email).
15    pub author: String,
16    /// Unix timestamp (seconds since epoch) when the commit was created.
17    pub created_at: u64,
18    /// Human-readable commit message.
19    pub message: String,
20    /// Version number of the parent commit, or `None` for the initial commit.
21    pub parent_version: Option<u32>,
22}
23
24/// Line-by-line diff between two prompt versions.
25#[derive(Debug, Clone, PartialEq)]
26pub struct PromptDiff {
27    /// Lines present in `b` but not in `a`.
28    pub added_lines: Vec<String>,
29    /// Lines present in `a` but not in `b`.
30    pub removed_lines: Vec<String>,
31    /// Number of lines common to both versions.
32    pub unchanged_lines: usize,
33}
34
35impl PromptDiff {
36    /// Returns `true` when there are no additions or removals.
37    pub fn is_empty(&self) -> bool {
38        self.added_lines.is_empty() && self.removed_lines.is_empty()
39    }
40
41    /// Short human-readable summary: `"+N -M ~K"`.
42    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
52/// Compute a line-by-line diff of two strings using the Wagner-Fischer LCS algorithm.
53///
54/// Returns a [`PromptDiff`] describing lines added, removed, and unchanged.
55pub 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    // Build the LCS table (Wagner-Fischer).
63    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    // Back-track to classify each line.
75    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    // Reverse so lines appear in document order.
96    added.reverse();
97    removed.reverse();
98
99    PromptDiff {
100        added_lines: added,
101        removed_lines: removed,
102        unchanged_lines: unchanged,
103    }
104}
105
106/// An append-only store of prompt versions with diff, rollback, and blame support.
107#[derive(Debug, Default)]
108pub struct PromptRepository {
109    versions: Vec<PromptVersion>,
110    next_version: u32,
111}
112
113impl PromptRepository {
114    /// Create an empty repository.
115    pub fn new() -> Self {
116        Self {
117            versions: Vec::new(),
118            next_version: 1,
119        }
120    }
121
122    /// Commit new content and return the assigned version number.
123    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    /// Return a reference to the [`PromptVersion`] with the given number, if it exists.
139    pub fn checkout(&self, version: u32) -> Option<&PromptVersion> {
140        self.versions
141            .iter()
142            .find(|v| v.version == version)
143    }
144
145    /// Compute the diff between two committed versions.
146    ///
147    /// Returns `None` if either version does not exist.
148    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    /// Return all versions in chronological (ascending version number) order.
155    pub fn log(&self) -> Vec<&PromptVersion> {
156        self.versions.iter().collect()
157    }
158
159    /// Create a new commit whose content matches `to_version`, effectively
160    /// reverting to that state. Returns the new version number, or `None` if
161    /// `to_version` does not exist.
162    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    /// Return the version that last introduced (or modified) the given 0-based
171    /// line index in the current HEAD content.
172    ///
173    /// Iterates versions in reverse order and returns the first version where
174    /// the specified line differs from its parent.  Returns `None` if the
175    /// repository is empty or the line index is out of range.
176    pub fn blame(&self, line: usize) -> Option<&PromptVersion> {
177        // The line must exist in the latest version.
178        let latest = self.versions.last()?;
179        if line >= latest.content.lines().count() {
180            return None;
181        }
182
183        // Walk versions from newest to oldest.
184        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                // Initial commit — this version introduced the line.
192                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        // Line unchanged since the first commit.
202        self.versions.first()
203    }
204
205    /// Return all versions committed by `author`, in chronological order.
206    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
214/// Returns the current Unix timestamp in seconds.
215///
216/// Uses `std::time::SystemTime` so there is no external dependency.
217fn 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    // ── diff_prompts ──────────────────────────────────────────────────────────
230
231    #[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        // "b" and "c" removed; "d", "e", "f" added; "a" unchanged
271        let summary = d.summary();
272        assert!(summary.starts_with('+'));
273        assert!(summary.contains('-'));
274        assert!(summary.contains('~'));
275    }
276
277    // ── PromptRepository ─────────────────────────────────────────────────────
278
279    #[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}