use crate::commands::init_library_manager;
use crate::config::{SyncDirection, load_sync_settings};
use crate::error::{SnipError, SnipResult};
use crate::library::LibraryManager;
use crate::proto::Library;
use std::io::{self, Write};
fn server_library_filename(name: &str) -> String {
name.to_lowercase().replace(' ', "-")
}
fn has_local_snippets(lib_path: &std::path::Path) -> bool {
lib_path.exists()
&& crate::library::load_library(lib_path)
.is_ok_and(|snippets| snippets.snippets.iter().any(|snippet| !snippet.deleted))
}
fn link_library_to_server(filename: &str, server_id: &str, mgr: &mut LibraryManager) -> bool {
if let Err(e) = mgr.link_server_library(filename, server_id) {
eprintln!(" Failed to link '{filename}': {e}");
return false;
}
true
}
fn clear_library_for_server_pull(
filename: &str,
lib_path: &std::path::Path,
server_id: &str,
mgr: &mut LibraryManager,
) -> bool {
if !link_library_to_server(filename, server_id, mgr) {
return false;
}
let empty = crate::library::Snippets::default();
if let Err(e) = crate::library::save_library(lib_path, &empty) {
eprintln!(" Failed to clear original library: {e}");
if let Err(unlink_err) = mgr.unlink_server_library(filename) {
eprintln!(" Failed to roll back server link: {unlink_err}");
}
return false;
}
true
}
fn link_server_library(lib: &Library, mgr: &mut LibraryManager, print_linked: bool) -> bool {
let filename = server_library_filename(&lib.name);
let existing_lib_id = mgr
.get_library_by_filename(&filename)
.map(|l| l.library_id.clone());
if let Some(existing_id) = &existing_lib_id {
if !existing_id.is_empty() && existing_id != &lib.id {
println!(" Library '{}' has different server ID, skipping", lib.name);
return false;
}
let lib_path = mgr.get_libraries_dir().join(format!("{filename}.toml"));
let local_has_content = has_local_snippets(&lib_path);
if existing_id.is_empty() && local_has_content && lib.snippet_count > 0 {
println!("\n Local library '{filename}' has snippets. Server also has snippets.");
match prompt_conflict(&filename).as_deref() {
Some("overwrite") => {
println!(" Replacing local library with server version");
return clear_library_for_server_pull(&filename, &lib_path, &lib.id, mgr);
}
Some("rename") => {
let new_name = format!("{filename}_local");
println!(" Renaming to '{new_name}' and pulling from server");
if let Err(e) = mgr.create_library(&new_name) {
eprintln!(" Failed to create backup: {e}");
return false;
}
let local_snippets =
crate::library::load_library(&lib_path).unwrap_or_default();
let backup_path = mgr.get_libraries_dir().join(format!("{new_name}.toml"));
if let Err(e) = crate::library::save_library(&backup_path, &local_snippets) {
eprintln!(" Failed to save backup: {e}");
return false;
}
if !clear_library_for_server_pull(&filename, &lib_path, &lib.id, mgr) {
return false;
}
println!(
" Created '{new_name}' with local content, original cleared for server content"
);
return true;
}
_ => {
println!(" Skipping, keeping local version");
return false;
}
}
}
if existing_id.is_empty() {
if !link_library_to_server(&filename, &lib.id, mgr) {
return false;
}
println!(" Linked '{}' to server library '{}'", filename, lib.id);
return true;
} else if print_linked {
println!(" Library '{}' already linked, skipping", lib.name);
}
return false;
}
match mgr.add_server_library(&lib.name, &lib.id) {
Ok(path) => {
println!(" Created '{}' at {}", lib.name, path.display());
true
}
Err(e) => {
eprintln!(" Failed to create library '{}': {}", lib.name, e);
false
}
}
}
pub fn prompt_conflict(lib_name: &str) -> Option<String> {
println!("\nConflict: Local library '{lib_name}' has different content than server");
println!(" (s)kip - keep local version");
println!(" (o)verwrite - replace with server version");
println!(" (r)ename - rename local and pull from server");
print!("Choice [s/o/r]: ");
io::stdout().flush().ok();
let mut input = String::new();
if io::stdin().read_line(&mut input).is_ok() {
match input.trim().to_lowercase().as_str() {
"o" => Some("overwrite".to_string()),
"r" => Some("rename".to_string()),
_ => None,
}
} else {
None
}
}
pub struct SyncOptions {
pub library: Option<String>,
pub servers: bool,
pub push_only: bool,
pub pull_only: bool,
pub dry_run: bool,
}
pub fn run(options: SyncOptions, runtime: &tokio::runtime::Runtime) -> SnipResult<()> {
let sync_settings = load_sync_settings().map_err(|e| {
eprintln!("Failed to load sync settings: {e}");
e
})?;
if !sync_settings.enabled {
eprintln!("Sync is not enabled. Configure sync settings first.");
return Ok(());
}
if sync_settings.api_key.is_empty() {
eprintln!("Sync is enabled but no API key is configured. Run 'snp register --force'.");
return Ok(());
}
if options.servers {
let mut client = runtime
.block_on(crate::sync::SyncClient::create(sync_settings.clone()))
.map_err(|e| {
SnipError::runtime_error("Failed to create sync client", Some(&e.to_string()))
})?;
match runtime.block_on(client.list_libraries()) {
Ok(libs) => {
println!("Server libraries:");
for lib in libs {
println!(" {} ({})", lib.name, lib.id);
}
}
Err(e) => eprintln!("Failed to list server libraries: {e}"),
}
return Ok(());
}
let mut client = runtime
.block_on(crate::sync::SyncClient::create(sync_settings.clone()))
.map_err(|e| {
SnipError::runtime_error("Failed to create sync client", Some(&e.to_string()))
})?;
match runtime.block_on(client.list_libraries()) {
Ok(libs) => {
let mut mgr = init_library_manager().map_err(|e| {
SnipError::runtime_error(
"Failed to initialize library manager",
Some(&e.to_string()),
)
})?;
if options.dry_run {
println!("\n[DRY RUN] Would sync snippets:");
let lib_path = match crate::commands::get_library_path(options.library)? {
Some(p) => p,
None => {
println!(" No library selected");
return Ok(());
}
};
let snippets = crate::library::load_library(&lib_path)?;
let direction = if options.push_only {
"push to server"
} else if options.pull_only {
"pull from server"
} else {
"bidirectional"
};
println!(" Direction: {direction}");
println!(" Snippets in library: {}", snippets.snippets.len());
for s in &snippets.snippets {
if !s.deleted {
println!(" - {} ({})", s.description, &s.id[..8.min(s.id.len())]);
}
}
return Ok(());
}
for lib in libs {
link_server_library(&lib, &mut mgr, true);
}
println!("\nSyncing snippets...");
let effective_push = options.push_only
|| (!options.pull_only && sync_settings.sync_direction == SyncDirection::Push);
let effective_pull = options.pull_only
|| (!options.push_only && sync_settings.sync_direction == SyncDirection::Pull);
crate::sync_commands::run_sync(
&sync_settings,
options.library.as_deref(),
effective_push,
effective_pull,
runtime,
)?;
Ok(())
}
Err(e) => {
eprintln!("Failed to pull libraries: {e}");
Err(SnipError::runtime_error(
"Failed to list server libraries",
Some(&e.to_string()),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_library_filename_slug() {
let cases = vec![
("My Library", "my-library"),
("UPPERCASE", "uppercase"),
("multi word name", "multi-word-name"),
];
for (input, expected) in cases {
assert_eq!(server_library_filename(input), expected);
}
}
#[test]
fn test_has_local_snippets_ignores_empty_and_deleted() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("library.toml");
assert!(!has_local_snippets(&path));
crate::library::save_library(&path, &crate::library::Snippets::default()).unwrap();
assert!(!has_local_snippets(&path));
let deleted = crate::library::Snippets {
snippets: vec![crate::library::Snippet {
id: "deleted".to_string(),
description: "deleted".to_string(),
command: "echo deleted".to_string(),
deleted: true,
..Default::default()
}],
..Default::default()
};
crate::library::save_library(&path, &deleted).unwrap();
assert!(!has_local_snippets(&path));
}
#[test]
fn test_has_local_snippets_detects_active_snippet() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("library.toml");
let snippets = crate::library::Snippets {
snippets: vec![crate::library::Snippet {
id: "active".to_string(),
description: "active".to_string(),
command: "echo active".to_string(),
..Default::default()
}],
..Default::default()
};
crate::library::save_library(&path, &snippets).unwrap();
assert!(has_local_snippets(&path));
}
#[test]
fn test_sync_options_defaults() {
let opts = SyncOptions {
library: None,
servers: false,
push_only: false,
pull_only: false,
dry_run: false,
};
assert!(!opts.servers);
assert!(!opts.push_only);
assert!(!opts.pull_only);
}
}