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 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 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
128pub 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
152pub 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
161pub 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 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 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}