systemprompt_cli/commands/cloud/sync/content/
mod.rs1mod display;
2
3use crate::cli_settings::CliConfig;
4use crate::cloud::sync::ContentSyncArgs;
5use anyhow::{Context, Result};
6use dialoguer::{Confirm, Select};
7use std::sync::Arc;
8use systemprompt_database::{Database, DbPool};
9use systemprompt_logging::CliService;
10use systemprompt_models::{AppPaths, ContentConfigRaw, ContentSourceConfigRaw, SecretsBootstrap};
11use systemprompt_sync::{ContentDiffEntry, ContentLocalSync, LocalSyncDirection};
12
13fn get_content_config_path() -> Result<std::path::PathBuf> {
14 let paths = AppPaths::get().map_err(|e| anyhow::anyhow!("{}", e))?;
15 let path = paths.system().content_config().to_path_buf();
16
17 if !path.exists() {
18 anyhow::bail!(
19 "Profile Error: Content config path does not exist\n\n Path: {}\n Field: \
20 paths.content_config\n\n To fix: Ensure the path exists or update your profile",
21 path.display()
22 );
23 }
24 Ok(path)
25}
26
27fn load_content_config() -> Result<ContentConfigRaw> {
28 let config_path = get_content_config_path()?;
29 let content = std::fs::read_to_string(&config_path)
30 .context(format!("Failed to read config: {}", config_path.display()))?;
31 let config: ContentConfigRaw =
32 serde_yaml::from_str(&content).context("Failed to parse content config")?;
33 Ok(config)
34}
35
36fn resolve_source_path(path: &str, services_path: &std::path::Path) -> std::path::PathBuf {
37 let path = std::path::Path::new(path);
38 if path.is_absolute() {
39 path.to_path_buf()
40 } else {
41 services_path.join(path)
42 }
43}
44
45async fn create_db_pool(database_url: Option<&str>) -> Result<DbPool> {
46 let url = match database_url {
47 Some(url) => url.to_string(),
48 None => SecretsBootstrap::database_url()?.to_string(),
49 };
50
51 let database = Database::from_config("postgres", &url)
52 .await
53 .context("Failed to connect to database")?;
54
55 Ok(Arc::new(database))
56}
57
58pub async fn execute(args: ContentSyncArgs, config: &CliConfig) -> Result<()> {
59 CliService::section("Content Sync");
60
61 let spinner = CliService::spinner("Connecting to database...");
62 let db = create_db_pool(args.database_url.as_deref()).await?;
63 spinner.finish_and_clear();
64
65 let content_config = load_content_config()?;
66
67 let sources: Vec<(String, ContentSourceConfigRaw)> = content_config
68 .content_sources
69 .into_iter()
70 .filter(|(_, source)| source.enabled)
71 .filter(|(name, _)| {
72 args.source
73 .as_ref()
74 .is_none_or(|filter| name.as_str() == filter.as_str())
75 })
76 .filter(|(_, source)| !source.allowed_content_types.contains(&"skill".to_string()))
77 .collect();
78
79 if sources.is_empty() {
80 if let Some(ref filter) = args.source {
81 CliService::warning(&format!("No content source found matching: {}", filter));
82 } else {
83 CliService::warning("No enabled content sources found");
84 }
85 return Ok(());
86 }
87
88 let sync = ContentLocalSync::new(Arc::clone(&db));
89 let mut all_diffs: Vec<ContentDiffEntry> = Vec::new();
90
91 let spinner = CliService::spinner("Calculating diff...");
92 let paths = AppPaths::get().map_err(|e| anyhow::anyhow!("{}", e))?;
93 let services_path = paths.system().services();
94 for (name, source) in sources {
95 let source_path = resolve_source_path(&source.path, services_path);
96
97 let diff = sync
98 .calculate_diff(
99 source.source_id.as_str(),
100 &source_path,
101 &source.allowed_content_types,
102 )
103 .await
104 .context(format!("Failed to calculate diff for source: {}", name))?;
105
106 all_diffs.push(ContentDiffEntry {
107 name,
108 source_id: source.source_id.to_string(),
109 category_id: source.category_id.to_string(),
110 path: source_path,
111 allowed_content_types: source.allowed_content_types.clone(),
112 diff,
113 });
114 }
115 spinner.finish_and_clear();
116
117 display::display_diff_summary(&all_diffs);
118
119 let has_changes = all_diffs.iter().any(|e| e.diff.has_changes());
120
121 if !has_changes {
122 CliService::success("Content is in sync - no changes needed");
123 return Ok(());
124 }
125
126 let direction = match args.direction {
127 Some(crate::cloud::sync::CliLocalSyncDirection::ToDisk) => LocalSyncDirection::ToDisk,
128 Some(crate::cloud::sync::CliLocalSyncDirection::ToDb) => LocalSyncDirection::ToDatabase,
129 None => {
130 if !config.is_interactive() {
131 anyhow::bail!("--direction is required in non-interactive mode");
132 }
133 if let Some(dir) = prompt_sync_direction()? {
134 dir
135 } else {
136 CliService::info("Sync cancelled");
137 return Ok(());
138 }
139 },
140 };
141
142 if args.dry_run {
143 CliService::info("Dry run - no changes made");
144 return Ok(());
145 }
146
147 if !args.yes && config.is_interactive() {
148 let confirmed = Confirm::new()
149 .with_prompt("Proceed with sync?")
150 .default(false)
151 .interact()?;
152
153 if !confirmed {
154 CliService::info("Sync cancelled");
155 return Ok(());
156 }
157 }
158
159 let spinner = CliService::spinner("Syncing content...");
160 let result = match direction {
161 LocalSyncDirection::ToDisk => sync.sync_to_disk(&all_diffs, args.delete_orphans).await?,
162 LocalSyncDirection::ToDatabase => sync.sync_to_db(&all_diffs, args.delete_orphans).await?,
163 };
164 spinner.finish_and_clear();
165
166 display::display_sync_result(&result);
167
168 Ok(())
169}
170
171fn prompt_sync_direction() -> Result<Option<LocalSyncDirection>> {
172 let options = vec![
173 "Sync to disk (DB -> Disk)",
174 "Sync to database (Disk -> DB)",
175 "Cancel",
176 ];
177
178 let selection = Select::new()
179 .with_prompt("Choose sync direction")
180 .items(&options)
181 .default(0)
182 .interact()?;
183
184 match selection {
185 0 => Ok(Some(LocalSyncDirection::ToDisk)),
186 1 => Ok(Some(LocalSyncDirection::ToDatabase)),
187 _ => Ok(None),
188 }
189}