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