Skip to main content

systemprompt_cli/commands/core/playbooks/
sync.rs

1use anyhow::{Context, Result};
2use clap::{Args, ValueEnum};
3use dialoguer::theme::ColorfulTheme;
4use dialoguer::{Confirm, Select};
5use std::sync::Arc;
6
7use super::types::PlaybookSyncOutput;
8use crate::shared::CommandResult;
9use crate::CliConfig;
10use systemprompt_database::{Database, DatabaseProvider};
11use systemprompt_logging::CliService;
12use systemprompt_models::{ProfileBootstrap, SecretsBootstrap};
13use systemprompt_sync::{LocalSyncDirection, PlaybooksDiffResult, PlaybooksLocalSync};
14
15#[derive(Debug, Clone, Copy, ValueEnum)]
16pub enum SyncDirection {
17    ToDb,
18    ToDisk,
19}
20
21#[derive(Debug, Clone, Copy, Args)]
22pub struct SyncArgs {
23    #[arg(long, value_enum, help = "Sync direction")]
24    pub direction: Option<SyncDirection>,
25
26    #[arg(long, help = "Show what would happen without making changes")]
27    pub dry_run: bool,
28
29    #[arg(long, help = "Delete items that only exist in target")]
30    pub delete_orphans: bool,
31
32    #[arg(short = 'y', long, help = "Skip confirmation prompts")]
33    pub yes: bool,
34}
35
36pub async fn execute(
37    args: SyncArgs,
38    config: &CliConfig,
39) -> Result<CommandResult<PlaybookSyncOutput>> {
40    CliService::section("Playbooks Sync");
41
42    let spinner = CliService::spinner("Connecting to database...");
43    let db = create_db_provider().await?;
44    spinner.finish_and_clear();
45
46    let playbooks_path = get_playbooks_path()?;
47
48    if !playbooks_path.exists() {
49        anyhow::bail!(
50            "Playbooks path does not exist: {}\nEnsure the path exists or update your profile",
51            playbooks_path.display()
52        );
53    }
54
55    let sync = PlaybooksLocalSync::new(Arc::clone(&db), playbooks_path.clone());
56    let spinner = CliService::spinner("Calculating diff...");
57    let diff = sync
58        .calculate_diff()
59        .await
60        .context("Failed to calculate playbooks diff")?;
61    spinner.finish_and_clear();
62
63    display_diff_summary(&diff);
64
65    if !diff.has_changes() {
66        CliService::success("Playbooks are in sync - no changes needed");
67        return Ok(CommandResult::text(PlaybookSyncOutput {
68            direction: "none".to_string(),
69            synced: 0,
70            skipped: 0,
71            deleted: 0,
72            errors: vec![],
73        })
74        .with_title("Playbooks Sync"));
75    }
76
77    let direction = match args.direction {
78        Some(SyncDirection::ToDisk) => LocalSyncDirection::ToDisk,
79        Some(SyncDirection::ToDb) => LocalSyncDirection::ToDatabase,
80        None => {
81            if args.dry_run {
82                LocalSyncDirection::ToDatabase
83            } else if !config.is_interactive() {
84                anyhow::bail!("--direction is required in non-interactive mode");
85            } else {
86                let Some(dir) = prompt_sync_direction()? else {
87                    CliService::info("Sync cancelled");
88                    return Ok(CommandResult::text(PlaybookSyncOutput {
89                        direction: "cancelled".to_string(),
90                        synced: 0,
91                        skipped: 0,
92                        deleted: 0,
93                        errors: vec![],
94                    })
95                    .with_title("Playbooks Sync"));
96                };
97                dir
98            }
99        },
100    };
101
102    if args.dry_run {
103        CliService::info("[Dry Run] No changes made");
104        let direction_str = match direction {
105            LocalSyncDirection::ToDisk => "to-disk",
106            LocalSyncDirection::ToDatabase => "to-db",
107        };
108        return Ok(CommandResult::text(PlaybookSyncOutput {
109            direction: format!("{} (dry-run)", direction_str),
110            synced: 0,
111            skipped: 0,
112            deleted: 0,
113            errors: vec![],
114        })
115        .with_title("Playbooks Sync (Dry Run)"));
116    }
117
118    if !args.yes && config.is_interactive() {
119        let confirmed = Confirm::with_theme(&ColorfulTheme::default())
120            .with_prompt("Proceed with sync?")
121            .default(false)
122            .interact()
123            .context("Failed to get confirmation")?;
124
125        if !confirmed {
126            CliService::info("Sync cancelled");
127            return Ok(CommandResult::text(PlaybookSyncOutput {
128                direction: "cancelled".to_string(),
129                synced: 0,
130                skipped: 0,
131                deleted: 0,
132                errors: vec![],
133            })
134            .with_title("Playbooks Sync"));
135        }
136    }
137
138    let spinner = CliService::spinner("Syncing playbooks...");
139    let result = match direction {
140        LocalSyncDirection::ToDisk => sync.sync_to_disk(&diff, args.delete_orphans).await?,
141        LocalSyncDirection::ToDatabase => sync.sync_to_db(&diff, args.delete_orphans).await?,
142    };
143    spinner.finish_and_clear();
144
145    CliService::section("Sync Complete");
146    CliService::key_value("Direction", &result.direction);
147    CliService::key_value("Synced", &result.items_synced.to_string());
148    CliService::key_value("Deleted", &result.items_deleted.to_string());
149    CliService::key_value("Skipped", &result.items_skipped.to_string());
150
151    if !result.errors.is_empty() {
152        CliService::warning(&format!("Errors ({})", result.errors.len()));
153        for error in &result.errors {
154            CliService::error(&format!("  {}", error));
155        }
156    }
157
158    let output = PlaybookSyncOutput {
159        direction: result.direction,
160        synced: result.items_synced,
161        skipped: result.items_skipped,
162        deleted: result.items_deleted,
163        errors: result.errors,
164    };
165
166    Ok(CommandResult::text(output).with_title("Playbooks Sync"))
167}
168
169fn get_playbooks_path() -> Result<std::path::PathBuf> {
170    let profile = ProfileBootstrap::get().context("Failed to get profile")?;
171    Ok(std::path::PathBuf::from(format!(
172        "{}/playbook",
173        profile.paths.services
174    )))
175}
176
177async fn create_db_provider() -> Result<Arc<dyn DatabaseProvider>> {
178    let url = SecretsBootstrap::database_url()
179        .context("Database URL not configured")?
180        .to_string();
181
182    let database = Database::from_config("postgres", &url)
183        .await
184        .context("Failed to connect to database")?;
185
186    Ok(Arc::new(database))
187}
188
189fn display_diff_summary(diff: &PlaybooksDiffResult) {
190    CliService::section("Playbooks Status");
191    CliService::info(&format!("{} unchanged", diff.unchanged));
192
193    if !diff.added.is_empty() {
194        CliService::info(&format!("+ {} (on disk, not in DB)", diff.added.len()));
195        for item in &diff.added {
196            let name = item.name.as_deref().unwrap_or("unnamed");
197            CliService::info(&format!("    + {} ({})", item.playbook_id, name));
198        }
199    }
200
201    if !diff.removed.is_empty() {
202        CliService::info(&format!("- {} (in DB, not on disk)", diff.removed.len()));
203        for item in &diff.removed {
204            let name = item.name.as_deref().unwrap_or("unnamed");
205            CliService::info(&format!("    - {} ({})", item.playbook_id, name));
206        }
207    }
208
209    if !diff.modified.is_empty() {
210        CliService::info(&format!("~ {} (modified)", diff.modified.len()));
211        for item in &diff.modified {
212            let name = item.name.as_deref().unwrap_or("unnamed");
213            CliService::info(&format!("    ~ {} ({})", item.playbook_id, name));
214        }
215    }
216}
217
218fn prompt_sync_direction() -> Result<Option<LocalSyncDirection>> {
219    let options = vec![
220        "Sync to database (Disk -> DB)",
221        "Sync to disk (DB -> Disk)",
222        "Cancel",
223    ];
224
225    let selection = Select::with_theme(&ColorfulTheme::default())
226        .with_prompt("Choose sync direction")
227        .items(&options)
228        .default(0)
229        .interact()
230        .context("Failed to get direction selection")?;
231
232    match selection {
233        0 => Ok(Some(LocalSyncDirection::ToDatabase)),
234        1 => Ok(Some(LocalSyncDirection::ToDisk)),
235        _ => Ok(None),
236    }
237}