Skip to main content

pebble_cms/services/
versions.rs

1//! Content versioning service
2//!
3//! Provides functionality for tracking content revisions, viewing history,
4//! comparing versions, and restoring previous versions.
5
6use crate::Database;
7use anyhow::Result;
8use rusqlite::Connection;
9use serde::Serialize;
10
11/// A full snapshot of content at a point in time
12#[derive(Debug, Clone, Serialize)]
13pub struct ContentVersion {
14    pub id: i64,
15    pub content_id: i64,
16    pub version_number: i64,
17    pub title: String,
18    pub slug: String,
19    pub body_markdown: String,
20    pub excerpt: Option<String>,
21    pub featured_image: Option<String>,
22    pub metadata: serde_json::Value,
23    pub tags: Vec<String>,
24    pub created_by: Option<i64>,
25    pub created_at: String,
26}
27
28/// Summary information for version history lists
29#[derive(Debug, Clone, Serialize)]
30pub struct VersionSummary {
31    pub id: i64,
32    pub version_number: i64,
33    pub title: String,
34    pub created_by_username: Option<String>,
35    pub created_at: String,
36    pub changes_summary: String,
37}
38
39/// Diff between two versions
40#[derive(Debug, Clone, Serialize)]
41pub struct VersionDiff {
42    pub old_version: ContentVersion,
43    pub new_version: ContentVersion,
44    pub title_changed: bool,
45    pub slug_changed: bool,
46    pub excerpt_changed: bool,
47    pub tags_changed: bool,
48    pub body_diff: Vec<DiffLine>,
49}
50
51/// A single line in a diff
52#[derive(Debug, Clone, Serialize)]
53pub struct DiffLine {
54    pub line_type: DiffLineType,
55    pub content: String,
56}
57
58#[derive(Debug, Clone, Serialize, PartialEq)]
59#[serde(rename_all = "lowercase")]
60pub enum DiffLineType {
61    Same,
62    Added,
63    Removed,
64}
65
66/// Create a version snapshot of the current content state.
67/// Call this BEFORE applying updates to preserve the previous state.
68pub fn create_version(db: &Database, content_id: i64, user_id: Option<i64>) -> Result<i64> {
69    let conn = db.get()?;
70
71    // Fetch current content state
72    let (title, slug, body_markdown, excerpt, featured_image, metadata): (
73        String,
74        String,
75        String,
76        Option<String>,
77        Option<String>,
78        String,
79    ) = conn.query_row(
80        "SELECT title, slug, body_markdown, excerpt, featured_image, metadata FROM content WHERE id = ?",
81        [content_id],
82        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?)),
83    )?;
84
85    // Fetch current tags
86    let tags = get_content_tags(&conn, content_id)?;
87    let tags_json = serde_json::to_string(&tags)?;
88
89    // Get next version number
90    let version_number = next_version_number(&conn, content_id)?;
91
92    // Insert version
93    conn.execute(
94        r#"
95        INSERT INTO content_versions
96            (content_id, version_number, title, slug, body_markdown, excerpt, featured_image, metadata, tags_json, created_by)
97        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
98        "#,
99        rusqlite::params![
100            content_id,
101            version_number,
102            title,
103            slug,
104            body_markdown,
105            excerpt,
106            featured_image,
107            metadata,
108            tags_json,
109            user_id,
110        ],
111    )?;
112
113    let version_id = conn.last_insert_rowid();
114    tracing::debug!(
115        "Created version {} (v{}) for content {}",
116        version_id,
117        version_number,
118        content_id
119    );
120
121    Ok(version_id)
122}
123
124/// List versions for a content item, newest first
125pub fn list_versions(
126    db: &Database,
127    content_id: i64,
128    limit: usize,
129    offset: usize,
130) -> Result<Vec<VersionSummary>> {
131    let conn = db.get()?;
132
133    let mut stmt = conn.prepare(
134        r#"
135        SELECT
136            cv.id,
137            cv.version_number,
138            cv.title,
139            u.username,
140            cv.created_at,
141            cv.body_markdown,
142            cv.slug,
143            cv.excerpt,
144            cv.tags_json
145        FROM content_versions cv
146        LEFT JOIN users u ON cv.created_by = u.id
147        WHERE cv.content_id = ?1
148        ORDER BY cv.version_number DESC
149        LIMIT ?2 OFFSET ?3
150        "#,
151    )?;
152
153    let versions: Vec<VersionSummary> = stmt
154        .query_map(rusqlite::params![content_id, limit, offset], |row| {
155            Ok((
156                row.get::<_, i64>(0)?,
157                row.get::<_, i64>(1)?,
158                row.get::<_, String>(2)?,
159                row.get::<_, Option<String>>(3)?,
160                row.get::<_, String>(4)?,
161                row.get::<_, String>(5)?,
162                row.get::<_, String>(6)?,
163                row.get::<_, Option<String>>(7)?,
164                row.get::<_, String>(8)?,
165            ))
166        })?
167        .collect::<Result<Vec<_>, _>>()?
168        .into_iter()
169        .map(
170            |(id, version_number, title, username, created_at, body, _slug, _excerpt, _tags)| {
171                let line_count = body.lines().count();
172                let changes_summary = format!("{} lines", line_count);
173
174                VersionSummary {
175                    id,
176                    version_number,
177                    title,
178                    created_by_username: username,
179                    created_at,
180                    changes_summary,
181                }
182            },
183        )
184        .collect();
185
186    Ok(versions)
187}
188
189/// Get full version details by version ID
190pub fn get_version(db: &Database, version_id: i64) -> Result<ContentVersion> {
191    let conn = db.get()?;
192
193    let version = conn.query_row(
194        r#"
195        SELECT id, content_id, version_number, title, slug, body_markdown,
196               excerpt, featured_image, metadata, tags_json, created_by, created_at
197        FROM content_versions
198        WHERE id = ?1
199        "#,
200        [version_id],
201        |row| {
202            Ok(ContentVersion {
203                id: row.get(0)?,
204                content_id: row.get(1)?,
205                version_number: row.get(2)?,
206                title: row.get(3)?,
207                slug: row.get(4)?,
208                body_markdown: row.get(5)?,
209                excerpt: row.get(6)?,
210                featured_image: row.get(7)?,
211                metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
212                tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
213                created_by: row.get(10)?,
214                created_at: row.get(11)?,
215            })
216        },
217    )?;
218
219    Ok(version)
220}
221
222/// Get version by content ID and version number
223pub fn get_version_by_number(
224    db: &Database,
225    content_id: i64,
226    version_number: i64,
227) -> Result<ContentVersion> {
228    let conn = db.get()?;
229
230    let version = conn.query_row(
231        r#"
232        SELECT id, content_id, version_number, title, slug, body_markdown,
233               excerpt, featured_image, metadata, tags_json, created_by, created_at
234        FROM content_versions
235        WHERE content_id = ?1 AND version_number = ?2
236        "#,
237        [content_id, version_number],
238        |row| {
239            Ok(ContentVersion {
240                id: row.get(0)?,
241                content_id: row.get(1)?,
242                version_number: row.get(2)?,
243                title: row.get(3)?,
244                slug: row.get(4)?,
245                body_markdown: row.get(5)?,
246                excerpt: row.get(6)?,
247                featured_image: row.get(7)?,
248                metadata: serde_json::from_str(&row.get::<_, String>(8)?).unwrap_or_default(),
249                tags: serde_json::from_str(&row.get::<_, String>(9)?).unwrap_or_default(),
250                created_by: row.get(10)?,
251                created_at: row.get(11)?,
252            })
253        },
254    )?;
255
256    Ok(version)
257}
258
259/// Restore content to a previous version.
260/// Creates a backup version of current state first, then applies the old version.
261pub fn restore_version(
262    db: &Database,
263    content_id: i64,
264    version_id: i64,
265    user_id: Option<i64>,
266) -> Result<()> {
267    // First, create a backup of current state
268    create_version(db, content_id, user_id)?;
269
270    // Get the version to restore
271    let version = get_version(db, version_id)?;
272
273    if version.content_id != content_id {
274        anyhow::bail!("Version does not belong to this content");
275    }
276
277    let mut conn = db.get()?;
278    let tx = conn.transaction()?;
279
280    // Update the content with the old version's data
281    tx.execute(
282        r#"
283        UPDATE content
284        SET title = ?1, slug = ?2, body_markdown = ?3, excerpt = ?4,
285            featured_image = ?5, metadata = ?6
286        WHERE id = ?7
287        "#,
288        rusqlite::params![
289            version.title,
290            version.slug,
291            version.body_markdown,
292            version.excerpt,
293            version.featured_image,
294            serde_json::to_string(&version.metadata)?,
295            content_id,
296        ],
297    )?;
298
299    // Re-render the markdown to HTML
300    let renderer = crate::services::markdown::MarkdownRenderer::new();
301    let body_html = renderer.render(&version.body_markdown);
302
303    tx.execute(
304        "UPDATE content SET body_html = ?1 WHERE id = ?2",
305        rusqlite::params![body_html, content_id],
306    )?;
307
308    // Restore tags
309    tx.execute(
310        "DELETE FROM content_tags WHERE content_id = ?1",
311        [content_id],
312    )?;
313
314    for tag_name in &version.tags {
315        // Get or create tag
316        let tag_id: i64 =
317            match tx.query_row("SELECT id FROM tags WHERE name = ?1", [tag_name], |row| {
318                row.get(0)
319            }) {
320                Ok(id) => id,
321                Err(_) => {
322                    let slug = crate::services::slug::generate_slug(tag_name);
323                    tx.execute(
324                        "INSERT INTO tags (name, slug) VALUES (?1, ?2)",
325                        rusqlite::params![tag_name, slug],
326                    )?;
327                    tx.last_insert_rowid()
328                }
329            };
330
331        tx.execute(
332            "INSERT OR IGNORE INTO content_tags (content_id, tag_id) VALUES (?1, ?2)",
333            [content_id, tag_id],
334        )?;
335    }
336
337    tx.commit()?;
338
339    tracing::info!(
340        "Restored content {} to version {} (v{})",
341        content_id,
342        version_id,
343        version.version_number
344    );
345
346    Ok(())
347}
348
349/// Generate a diff between two versions
350pub fn diff_versions(
351    db: &Database,
352    old_version_id: i64,
353    new_version_id: i64,
354) -> Result<VersionDiff> {
355    let old_version = get_version(db, old_version_id)?;
356    let new_version = get_version(db, new_version_id)?;
357
358    let title_changed = old_version.title != new_version.title;
359    let slug_changed = old_version.slug != new_version.slug;
360    let excerpt_changed = old_version.excerpt != new_version.excerpt;
361    let tags_changed = old_version.tags != new_version.tags;
362
363    let body_diff = compute_line_diff(&old_version.body_markdown, &new_version.body_markdown);
364
365    Ok(VersionDiff {
366        old_version,
367        new_version,
368        title_changed,
369        slug_changed,
370        excerpt_changed,
371        tags_changed,
372        body_diff,
373    })
374}
375
376/// Clean up old versions, keeping only the most recent `keep_count` versions
377pub fn cleanup_old_versions(db: &Database, content_id: i64, keep_count: usize) -> Result<usize> {
378    if keep_count == 0 {
379        return Ok(0); // Unlimited retention
380    }
381
382    let conn = db.get()?;
383
384    // Get version IDs to delete (all except the most recent keep_count)
385    let mut stmt = conn.prepare(
386        r#"
387        SELECT id FROM content_versions
388        WHERE content_id = ?1
389        ORDER BY version_number DESC
390        LIMIT -1 OFFSET ?2
391        "#,
392    )?;
393
394    let ids_to_delete: Vec<i64> = stmt
395        .query_map(rusqlite::params![content_id, keep_count], |row| row.get(0))?
396        .collect::<Result<Vec<_>, _>>()?;
397
398    if ids_to_delete.is_empty() {
399        return Ok(0);
400    }
401
402    let deleted = ids_to_delete.len();
403
404    for id in ids_to_delete {
405        conn.execute("DELETE FROM content_versions WHERE id = ?1", [id])?;
406    }
407
408    tracing::debug!(
409        "Cleaned up {} old versions for content {}",
410        deleted,
411        content_id
412    );
413
414    Ok(deleted)
415}
416
417/// Count total versions for a content item
418pub fn count_versions(db: &Database, content_id: i64) -> Result<i64> {
419    let conn = db.get()?;
420    let count: i64 = conn.query_row(
421        "SELECT COUNT(*) FROM content_versions WHERE content_id = ?1",
422        [content_id],
423        |row| row.get(0),
424    )?;
425    Ok(count)
426}
427
428/// Get the latest version number for a content item
429pub fn get_latest_version_number(db: &Database, content_id: i64) -> Result<Option<i64>> {
430    let conn = db.get()?;
431    let version: Option<i64> = conn
432        .query_row(
433            "SELECT MAX(version_number) FROM content_versions WHERE content_id = ?1",
434            [content_id],
435            |row| row.get(0),
436        )
437        .ok();
438    Ok(version)
439}
440
441// Helper functions
442
443fn next_version_number(conn: &Connection, content_id: i64) -> Result<i64> {
444    let max: Option<i64> = conn
445        .query_row(
446            "SELECT MAX(version_number) FROM content_versions WHERE content_id = ?1",
447            [content_id],
448            |row| row.get(0),
449        )
450        .unwrap_or(None);
451
452    Ok(max.unwrap_or(0) + 1)
453}
454
455fn get_content_tags(conn: &Connection, content_id: i64) -> Result<Vec<String>> {
456    let mut stmt = conn.prepare(
457        "SELECT t.name FROM tags t JOIN content_tags ct ON t.id = ct.tag_id WHERE ct.content_id = ?1",
458    )?;
459
460    let tags: Vec<String> = stmt
461        .query_map([content_id], |row| row.get(0))?
462        .collect::<Result<Vec<_>, _>>()?;
463
464    Ok(tags)
465}
466
467/// Compute a simple line-by-line diff between two texts
468fn compute_line_diff(old_text: &str, new_text: &str) -> Vec<DiffLine> {
469    let old_lines: Vec<&str> = old_text.lines().collect();
470    let new_lines: Vec<&str> = new_text.lines().collect();
471
472    let m = old_lines.len();
473    let n = new_lines.len();
474
475    // Build LCS table
476    let mut lcs = vec![vec![0usize; n + 1]; m + 1];
477    for i in 1..=m {
478        for j in 1..=n {
479            if old_lines[i - 1] == new_lines[j - 1] {
480                lcs[i][j] = lcs[i - 1][j - 1] + 1;
481            } else {
482                lcs[i][j] = lcs[i - 1][j].max(lcs[i][j - 1]);
483            }
484        }
485    }
486
487    // Backtrack to build diff
488    let mut i = m;
489    let mut j = n;
490    let mut result = Vec::new();
491
492    while i > 0 || j > 0 {
493        if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] {
494            result.push(DiffLine {
495                line_type: DiffLineType::Same,
496                content: old_lines[i - 1].to_string(),
497            });
498            i -= 1;
499            j -= 1;
500        } else if j > 0 && (i == 0 || lcs[i][j - 1] >= lcs[i - 1][j]) {
501            result.push(DiffLine {
502                line_type: DiffLineType::Added,
503                content: new_lines[j - 1].to_string(),
504            });
505            j -= 1;
506        } else {
507            result.push(DiffLine {
508                line_type: DiffLineType::Removed,
509                content: old_lines[i - 1].to_string(),
510            });
511            i -= 1;
512        }
513    }
514
515    result.reverse();
516    result
517}
518
519#[cfg(test)]
520mod tests {
521    use super::*;
522
523    #[test]
524    fn test_compute_line_diff_no_changes() {
525        let text = "line 1\nline 2\nline 3";
526        let diff = compute_line_diff(text, text);
527
528        assert_eq!(diff.len(), 3);
529        assert!(diff.iter().all(|d| d.line_type == DiffLineType::Same));
530    }
531
532    #[test]
533    fn test_compute_line_diff_added_line() {
534        let old = "line 1\nline 2";
535        let new = "line 1\nline 2\nline 3";
536        let diff = compute_line_diff(old, new);
537
538        assert_eq!(diff.len(), 3);
539        assert_eq!(diff[0].line_type, DiffLineType::Same);
540        assert_eq!(diff[1].line_type, DiffLineType::Same);
541        assert_eq!(diff[2].line_type, DiffLineType::Added);
542        assert_eq!(diff[2].content, "line 3");
543    }
544
545    #[test]
546    fn test_compute_line_diff_removed_line() {
547        let old = "line 1\nline 2\nline 3";
548        let new = "line 1\nline 3";
549        let diff = compute_line_diff(old, new);
550
551        let removed_count = diff
552            .iter()
553            .filter(|d| d.line_type == DiffLineType::Removed)
554            .count();
555        assert_eq!(removed_count, 1);
556    }
557
558    #[test]
559    fn test_compute_line_diff_modified_line() {
560        let old = "hello world";
561        let new = "hello rust";
562        let diff = compute_line_diff(old, new);
563
564        // Should show old removed and new added
565        assert!(diff
566            .iter()
567            .any(|d| d.line_type == DiffLineType::Removed && d.content == "hello world"));
568        assert!(diff
569            .iter()
570            .any(|d| d.line_type == DiffLineType::Added && d.content == "hello rust"));
571    }
572}