Skip to main content

snip_it/commands/
sync_cmd.rs

1use crate::commands::init_library_manager;
2use crate::config::{SyncDirection, load_sync_settings};
3use crate::error::{SnipError, SnipResult};
4use crate::library::LibraryManager;
5use crate::proto::Library;
6use std::io::{self, Write};
7
8fn server_library_filename(name: &str) -> String {
9    name.to_lowercase().replace(' ', "-")
10}
11
12fn has_local_snippets(lib_path: &std::path::Path) -> bool {
13    lib_path.exists()
14        && crate::library::load_library(lib_path)
15            .is_ok_and(|snippets| snippets.snippets.iter().any(|snippet| !snippet.deleted))
16}
17
18fn link_library_to_server(filename: &str, server_id: &str, mgr: &mut LibraryManager) -> bool {
19    if let Err(e) = mgr.link_server_library(filename, server_id) {
20        eprintln!("  Failed to link '{filename}': {e}");
21        return false;
22    }
23    true
24}
25
26fn clear_library_for_server_pull(
27    filename: &str,
28    lib_path: &std::path::Path,
29    server_id: &str,
30    mgr: &mut LibraryManager,
31) -> bool {
32    if !link_library_to_server(filename, server_id, mgr) {
33        return false;
34    }
35
36    let empty = crate::library::Snippets::default();
37    if let Err(e) = crate::library::save_library(lib_path, &empty) {
38        eprintln!("    Failed to clear original library: {e}");
39        if let Err(unlink_err) = mgr.unlink_server_library(filename) {
40            eprintln!("    Failed to roll back server link: {unlink_err}");
41        }
42        return false;
43    }
44
45    true
46}
47
48fn link_server_library(lib: &Library, mgr: &mut LibraryManager, print_linked: bool) -> bool {
49    let filename = server_library_filename(&lib.name);
50    let existing_lib_id = mgr
51        .get_library_by_filename(&filename)
52        .map(|l| l.library_id.clone());
53
54    if let Some(existing_id) = &existing_lib_id {
55        if !existing_id.is_empty() && existing_id != &lib.id {
56            println!("  Library '{}' has different server ID, skipping", lib.name);
57            return false;
58        }
59
60        let lib_path = mgr.get_libraries_dir().join(format!("{filename}.toml"));
61        let local_has_content = has_local_snippets(&lib_path);
62
63        if existing_id.is_empty() && local_has_content && lib.snippet_count > 0 {
64            println!("\n  Local library '{filename}' has snippets. Server also has snippets.");
65            match prompt_conflict(&filename).as_deref() {
66                Some("overwrite") => {
67                    println!("  Replacing local library with server version");
68                    return clear_library_for_server_pull(&filename, &lib_path, &lib.id, mgr);
69                }
70                Some("rename") => {
71                    let new_name = format!("{filename}_local");
72                    println!("  Renaming to '{new_name}' and pulling from server");
73                    if let Err(e) = mgr.create_library(&new_name) {
74                        eprintln!("    Failed to create backup: {e}");
75                        return false;
76                    }
77                    // Move local snippets to the backup library
78                    let local_snippets =
79                        crate::library::load_library(&lib_path).unwrap_or_default();
80                    let backup_path = mgr.get_libraries_dir().join(format!("{new_name}.toml"));
81                    if let Err(e) = crate::library::save_library(&backup_path, &local_snippets) {
82                        eprintln!("    Failed to save backup: {e}");
83                        return false;
84                    }
85                    // Link the original library to the server ID so server
86                    // content syncs into it. The backup stays unlinked (created
87                    // by create_library with empty library_id).
88                    // Clear original library for server content
89                    if !clear_library_for_server_pull(&filename, &lib_path, &lib.id, mgr) {
90                        return false;
91                    }
92                    println!(
93                        "    Created '{new_name}' with local content, original cleared for server content"
94                    );
95                    return true;
96                }
97                _ => {
98                    println!("  Skipping, keeping local version");
99                    return false;
100                }
101            }
102        }
103
104        if existing_id.is_empty() {
105            if !link_library_to_server(&filename, &lib.id, mgr) {
106                return false;
107            }
108            println!("  Linked '{}' to server library '{}'", filename, lib.id);
109            return true;
110        } else if print_linked {
111            println!("  Library '{}' already linked, skipping", lib.name);
112        }
113        return false;
114    }
115
116    match mgr.add_server_library(&lib.name, &lib.id) {
117        Ok(path) => {
118            println!("  Created '{}' at {}", lib.name, path.display());
119            true
120        }
121        Err(e) => {
122            eprintln!("  Failed to create library '{}': {}", lib.name, e);
123            false
124        }
125    }
126}
127
128/// Prompts the user to resolve a local/server library conflict.
129///
130/// Returns `"overwrite"`, `"rename"`, or `None` (skip) based on user input.
131pub fn prompt_conflict(lib_name: &str) -> Option<String> {
132    println!("\nConflict: Local library '{lib_name}' has different content than server");
133    println!("  (s)kip - keep local version");
134    println!("  (o)verwrite - replace with server version");
135    println!("  (r)ename - rename local and pull from server");
136    print!("Choice [s/o/r]: ");
137
138    io::stdout().flush().ok();
139
140    let mut input = String::new();
141    if io::stdin().read_line(&mut input).is_ok() {
142        match input.trim().to_lowercase().as_str() {
143            "o" => Some("overwrite".to_string()),
144            "r" => Some("rename".to_string()),
145            _ => None,
146        }
147    } else {
148        None
149    }
150}
151
152/// Options for the `sync` command.
153pub struct SyncOptions {
154    pub library: Option<String>,
155    pub servers: bool,
156    pub push_only: bool,
157    pub pull_only: bool,
158    pub dry_run: bool,
159}
160
161/// Runs the sync command with the given options.
162///
163/// Supports listing servers, push-only, pull-only, bidirectional, and dry-run modes.
164pub fn run(options: SyncOptions, runtime: &tokio::runtime::Runtime) -> SnipResult<()> {
165    let sync_settings = load_sync_settings().map_err(|e| {
166        eprintln!("Failed to load sync settings: {e}");
167        e
168    })?;
169
170    if !sync_settings.enabled {
171        eprintln!("Sync is not enabled. Configure sync settings first.");
172        return Ok(());
173    }
174    if sync_settings.api_key.is_empty() {
175        eprintln!("Sync is enabled but no API key is configured. Run 'snp register --force'.");
176        return Ok(());
177    }
178
179    if options.servers {
180        let mut client = runtime
181            .block_on(crate::sync::SyncClient::create(sync_settings.clone()))
182            .map_err(|e| {
183                SnipError::runtime_error("Failed to create sync client", Some(&e.to_string()))
184            })?;
185
186        match runtime.block_on(client.list_libraries()) {
187            Ok(libs) => {
188                println!("Server libraries:");
189                for lib in libs {
190                    println!("  {} ({})", lib.name, lib.id);
191                }
192            }
193            Err(e) => eprintln!("Failed to list server libraries: {e}"),
194        }
195        return Ok(());
196    }
197
198    let mut client = runtime
199        .block_on(crate::sync::SyncClient::create(sync_settings.clone()))
200        .map_err(|e| {
201            SnipError::runtime_error("Failed to create sync client", Some(&e.to_string()))
202        })?;
203
204    match runtime.block_on(client.list_libraries()) {
205        Ok(libs) => {
206            let mut mgr = init_library_manager().map_err(|e| {
207                SnipError::runtime_error(
208                    "Failed to initialize library manager",
209                    Some(&e.to_string()),
210                )
211            })?;
212
213            if options.dry_run {
214                println!("\n[DRY RUN] Would sync snippets:");
215                let lib_path = match crate::commands::get_library_path(options.library)? {
216                    Some(p) => p,
217                    None => {
218                        println!("  No library selected");
219                        return Ok(());
220                    }
221                };
222                let snippets = crate::library::load_library(&lib_path)?;
223                let direction = if options.push_only {
224                    "push to server"
225                } else if options.pull_only {
226                    "pull from server"
227                } else {
228                    "bidirectional"
229                };
230                println!("  Direction: {direction}");
231                println!("  Snippets in library: {}", snippets.snippets.len());
232                for s in &snippets.snippets {
233                    if !s.deleted {
234                        println!("  - {} ({})", s.description, &s.id[..8.min(s.id.len())]);
235                    }
236                }
237                return Ok(());
238            }
239
240            for lib in libs {
241                link_server_library(&lib, &mut mgr, true);
242            }
243
244            println!("\nSyncing snippets...");
245            // Respect config direction when no CLI flags are provided
246            // When both push and pull are effective (bidirectional), pass neither
247            // so run_sync defaults to Bidirectional instead of Push-only.
248            let effective_push = options.push_only
249                || (!options.pull_only && sync_settings.sync_direction == SyncDirection::Push);
250            let effective_pull = options.pull_only
251                || (!options.push_only && sync_settings.sync_direction == SyncDirection::Pull);
252            crate::sync_commands::run_sync(
253                &sync_settings,
254                options.library.as_deref(),
255                effective_push,
256                effective_pull,
257                runtime,
258            )?;
259            Ok(())
260        }
261        Err(e) => {
262            eprintln!("Failed to pull libraries: {e}");
263            Err(SnipError::runtime_error(
264                "Failed to list server libraries",
265                Some(&e.to_string()),
266            ))
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn test_library_filename_slug() {
277        // link_server_library derives filenames by lowercasing and replacing spaces.
278        // The function takes a &mut LibraryManager, but the slug transformation
279        // is the testable contract — verify the expected mapping directly.
280        let cases = vec![
281            ("My Library", "my-library"),
282            ("UPPERCASE", "uppercase"),
283            ("multi word name", "multi-word-name"),
284        ];
285        for (input, expected) in cases {
286            assert_eq!(server_library_filename(input), expected);
287        }
288    }
289
290    #[test]
291    fn test_has_local_snippets_ignores_empty_and_deleted() {
292        let tmp = tempfile::TempDir::new().unwrap();
293        let path = tmp.path().join("library.toml");
294
295        assert!(!has_local_snippets(&path));
296
297        crate::library::save_library(&path, &crate::library::Snippets::default()).unwrap();
298        assert!(!has_local_snippets(&path));
299
300        let deleted = crate::library::Snippets {
301            snippets: vec![crate::library::Snippet {
302                id: "deleted".to_string(),
303                description: "deleted".to_string(),
304                command: "echo deleted".to_string(),
305                deleted: true,
306                ..Default::default()
307            }],
308            ..Default::default()
309        };
310        crate::library::save_library(&path, &deleted).unwrap();
311        assert!(!has_local_snippets(&path));
312    }
313
314    #[test]
315    fn test_has_local_snippets_detects_active_snippet() {
316        let tmp = tempfile::TempDir::new().unwrap();
317        let path = tmp.path().join("library.toml");
318        let snippets = crate::library::Snippets {
319            snippets: vec![crate::library::Snippet {
320                id: "active".to_string(),
321                description: "active".to_string(),
322                command: "echo active".to_string(),
323                ..Default::default()
324            }],
325            ..Default::default()
326        };
327
328        crate::library::save_library(&path, &snippets).unwrap();
329
330        assert!(has_local_snippets(&path));
331    }
332
333    #[test]
334    fn test_sync_options_defaults() {
335        let opts = SyncOptions {
336            library: None,
337            servers: false,
338            push_only: false,
339            pull_only: false,
340            dry_run: false,
341        };
342        assert!(!opts.servers);
343        assert!(!opts.push_only);
344        assert!(!opts.pull_only);
345    }
346}