systemprompt_sync/diff/
content.rs1use 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}