Skip to main content

systemprompt_sync/diff/
content.rs

1//! Compute the diff between content stored on disk (markdown + frontmatter)
2//! and in the database for one content source.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use super::compute_content_hash;
8use crate::error::{SyncError, SyncResult};
9use crate::models::{ContentDiffItem, ContentDiffResult, DiffStatus, DiskContent};
10use std::collections::HashMap;
11use std::path::Path;
12use systemprompt_content::models::Content;
13use systemprompt_content::repository::ContentRepository;
14use systemprompt_database::DbPool;
15use systemprompt_identifiers::{LocaleCode, SourceId};
16use systemprompt_models::split_frontmatter;
17use tracing::warn;
18use walkdir::WalkDir;
19
20#[derive(Debug)]
21pub struct ContentDiffCalculator {
22    content_repo: ContentRepository,
23}
24
25impl ContentDiffCalculator {
26    pub fn new(db: &DbPool) -> SyncResult<Self> {
27        Ok(Self {
28            content_repo: ContentRepository::new(db).map_err(SyncError::internal)?,
29        })
30    }
31
32    pub async fn calculate_diff(
33        &self,
34        source_id: &SourceId,
35        disk_path: &Path,
36        allowed_types: &[String],
37    ) -> SyncResult<ContentDiffResult> {
38        let db_content = self
39            .content_repo
40            .list_by_source(source_id, &LocaleCode::new("en"))
41            .await
42            .map_err(SyncError::internal)?;
43        let db_map: HashMap<String, Content> = db_content
44            .into_iter()
45            .map(|c| (c.slug.clone(), c))
46            .collect();
47
48        let disk_items = Self::scan_disk_content(disk_path, allowed_types);
49
50        let mut result = ContentDiffResult {
51            source_id: source_id.clone(),
52            ..Default::default()
53        };
54
55        for (slug, disk_item) in &disk_items {
56            let disk_hash = compute_content_hash(&disk_item.body, &disk_item.title);
57
58            match db_map.get(slug) {
59                None => {
60                    result.added.push(ContentDiffItem {
61                        slug: slug.clone(),
62                        source_id: source_id.clone(),
63                        status: DiffStatus::Added,
64                        disk_hash: Some(disk_hash),
65                        db_hash: None,
66                        disk_updated_at: None,
67                        db_updated_at: None,
68                        title: Some(disk_item.title.clone()),
69                    });
70                },
71                Some(db_item) => {
72                    if db_item.version_hash == disk_hash {
73                        result.unchanged += 1;
74                    } else {
75                        result.modified.push(ContentDiffItem {
76                            slug: slug.clone(),
77                            source_id: source_id.clone(),
78                            status: DiffStatus::Modified,
79                            disk_hash: Some(disk_hash),
80                            db_hash: Some(db_item.version_hash.clone()),
81                            disk_updated_at: None,
82                            db_updated_at: Some(db_item.updated_at),
83                            title: Some(disk_item.title.clone()),
84                        });
85                    }
86                },
87            }
88        }
89
90        for (slug, db_item) in &db_map {
91            if !disk_items.contains_key(slug) {
92                result.removed.push(ContentDiffItem {
93                    slug: slug.clone(),
94                    source_id: source_id.clone(),
95                    status: DiffStatus::Removed,
96                    disk_hash: None,
97                    db_hash: Some(db_item.version_hash.clone()),
98                    disk_updated_at: None,
99                    db_updated_at: Some(db_item.updated_at),
100                    title: Some(db_item.title.clone()),
101                });
102            }
103        }
104
105        Ok(result)
106    }
107
108    fn scan_disk_content(path: &Path, allowed_types: &[String]) -> HashMap<String, DiskContent> {
109        let mut items = HashMap::new();
110
111        if !path.exists() {
112            return items;
113        }
114
115        for entry in WalkDir::new(path)
116            .into_iter()
117            .filter_map(|e| match e {
118                Ok(entry) => Some(entry),
119                Err(err) => {
120                    tracing::warn!(error = %err, "Failed to read directory entry during sync");
121                    None
122                },
123            })
124            .filter(|e| e.file_type().is_file())
125            .filter(|e| e.path().extension().is_some_and(|ext| ext == "md"))
126        {
127            let file_path = entry.path();
128            match parse_content_file(file_path, allowed_types) {
129                Ok(Some(content)) => {
130                    items.insert(content.slug.clone(), content);
131                },
132                Ok(None) => {},
133                Err(e) => {
134                    warn!("Failed to parse {}: {}", file_path.display(), e);
135                },
136            }
137        }
138
139        items
140    }
141}
142
143fn parse_content_file(path: &Path, allowed_types: &[String]) -> SyncResult<Option<DiskContent>> {
144    let content = std::fs::read_to_string(path)?;
145
146    let split = split_frontmatter(&content)
147        .ok_or_else(|| SyncError::invalid_input("Invalid frontmatter format"))?;
148
149    let frontmatter: serde_yaml::Value = serde_yaml::from_str(split.yaml)?;
150    let body = split.body.trim().to_owned();
151
152    let kind = frontmatter
153        .get("kind")
154        .and_then(|v| v.as_str())
155        .ok_or_else(|| SyncError::invalid_input("Missing kind in frontmatter"))?;
156
157    if !allowed_types.iter().any(|t| t == kind) {
158        return Ok(None);
159    }
160
161    let slug = frontmatter
162        .get("slug")
163        .and_then(|v| v.as_str())
164        .ok_or_else(|| SyncError::invalid_input("Missing slug in frontmatter"))?
165        .to_owned();
166
167    let title = frontmatter
168        .get("title")
169        .and_then(|v| v.as_str())
170        .ok_or_else(|| SyncError::invalid_input("Missing title in frontmatter"))?
171        .to_owned();
172
173    Ok(Some(DiskContent { slug, title, body }))
174}