Skip to main content

systemprompt_cli/commands/cloud/sync/content/
mod.rs

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