Skip to main content

systemprompt_sync/local/
content_sync.rs

1use 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}