systemprompt_sync/local/
content_sync.rs1use crate::diff::ContentDiffCalculator;
8use crate::error::{SyncError, SyncResult};
9use crate::export::export_content_to_file;
10use crate::models::{ContentDiffResult, LocalSyncDirection, LocalSyncResult};
11use std::path::{Path, PathBuf};
12use systemprompt_content::models::{IngestionOptions, IngestionSource};
13use systemprompt_content::repository::ContentRepository;
14use systemprompt_content::services::IngestionService;
15use systemprompt_database::DbPool;
16use systemprompt_identifiers::{CategoryId, ContentId, LocaleCode, SourceId};
17use tracing::info;
18
19#[derive(Debug)]
20pub struct ContentDiffEntry {
21 pub name: String,
22 pub source_id: SourceId,
23 pub category_id: CategoryId,
24 pub path: PathBuf,
25 pub allowed_content_types: Vec<String>,
26 pub diff: ContentDiffResult,
27}
28
29#[derive(Debug)]
30pub struct ContentLocalSync {
31 db: DbPool,
32}
33
34impl ContentLocalSync {
35 pub const fn new(db: DbPool) -> Self {
36 Self { db }
37 }
38
39 pub async fn calculate_diff(
40 &self,
41 source_id: &SourceId,
42 disk_path: &Path,
43 allowed_types: &[String],
44 ) -> SyncResult<ContentDiffResult> {
45 let calculator = ContentDiffCalculator::new(&self.db).map_err(SyncError::internal)?;
46 calculator
47 .calculate_diff(source_id, disk_path, allowed_types)
48 .await
49 .map_err(SyncError::internal)
50 }
51
52 pub async fn sync_to_disk(
53 &self,
54 diffs: &[ContentDiffEntry],
55 delete_orphans: bool,
56 ) -> SyncResult<LocalSyncResult> {
57 let content_repo = ContentRepository::new(&self.db).map_err(SyncError::internal)?;
58 let mut result = LocalSyncResult {
59 direction: LocalSyncDirection::ToDisk,
60 ..Default::default()
61 };
62
63 for entry in diffs {
64 let source_path = &entry.path;
65
66 for item in &entry.diff.modified {
67 match content_repo
68 .get_by_source_and_slug(&entry.source_id, &item.slug, &LocaleCode::new("en"))
69 .await
70 .map_err(SyncError::internal)?
71 {
72 Some(content) => {
73 export_content_to_file(&content, source_path, &entry.name)?;
74 result.items_synced += 1;
75 info!("Exported modified content: {}", item.slug);
76 },
77 None => {
78 result
79 .errors
80 .push(format!("Content not found in DB: {}", item.slug));
81 },
82 }
83 }
84
85 for item in &entry.diff.removed {
86 match content_repo
87 .get_by_source_and_slug(&entry.source_id, &item.slug, &LocaleCode::new("en"))
88 .await
89 .map_err(SyncError::internal)?
90 {
91 Some(content) => {
92 export_content_to_file(&content, source_path, &entry.name)?;
93 result.items_synced += 1;
94 info!("Created content on disk: {}", item.slug);
95 },
96 None => {
97 result
98 .errors
99 .push(format!("Content not found in DB: {}", item.slug));
100 },
101 }
102 }
103
104 if delete_orphans {
105 for item in &entry.diff.added {
106 let file_path = if entry.name == "blog" {
107 source_path.join(&item.slug).join("index.md")
108 } else {
109 source_path.join(format!("{}.md", item.slug))
110 };
111
112 if file_path.exists() {
113 if entry.name == "blog" {
114 std::fs::remove_dir_all(source_path.join(&item.slug))?;
115 } else {
116 std::fs::remove_file(&file_path)?;
117 }
118 result.items_deleted += 1;
119 info!("Deleted orphan content: {}", item.slug);
120 }
121 }
122 } else {
123 result.items_skipped += entry.diff.added.len();
124 }
125 }
126
127 Ok(result)
128 }
129
130 pub async fn sync_to_db(
131 &self,
132 diffs: &[ContentDiffEntry],
133 delete_orphans: bool,
134 override_existing: bool,
135 ) -> SyncResult<LocalSyncResult> {
136 let ingestion_service = IngestionService::new(&self.db).map_err(SyncError::internal)?;
137 let content_repo = ContentRepository::new(&self.db).map_err(SyncError::internal)?;
138 let mut result = LocalSyncResult {
139 direction: LocalSyncDirection::ToDatabase,
140 ..Default::default()
141 };
142
143 for entry in diffs {
144 let source_path = &entry.path;
145 let source = IngestionSource::new(&entry.source_id, &entry.name, &entry.category_id);
146 let report = ingestion_service
147 .ingest_directory(
148 source_path,
149 &source,
150 IngestionOptions::default()
151 .with_recursive(true)
152 .with_override(override_existing),
153 )
154 .await
155 .map_err(SyncError::internal)?;
156
157 result.items_synced += report.files_processed;
158 result.items_skipped_modified += report.skipped_count;
159
160 for error in report.errors {
161 result.errors.push(error);
162 }
163
164 info!(
165 "Ingested {} files from {}",
166 report.files_processed, entry.name
167 );
168
169 if delete_orphans {
170 for item in &entry.diff.removed {
171 let content_id = ContentId::new(&item.slug);
172 content_repo.delete(&content_id).await.map_err(|e| {
173 SyncError::internal(format!("Failed to delete {}: {e}", item.slug))
174 })?;
175 result.items_deleted += 1;
176 info!("Deleted from DB: {}", item.slug);
177 }
178 } else {
179 result.items_skipped += entry.diff.removed.len();
180 }
181 }
182
183 Ok(result)
184 }
185}