snip-it 1.3.1

Fast terminal snippet manager with fuzzy search, TUI, variable expansion, and end-to-end encrypted cross-device sync
Documentation
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;
                    }
                    // Move local snippets to the backup library
                    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;
                    }
                    // Link the original library to the server ID so server
                    // content syncs into it. The backup stays unlinked (created
                    // by create_library with empty library_id).
                    // Clear original library for server content
                    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
        }
    }
}

/// Prompts the user to resolve a local/server library conflict.
///
/// Returns `"overwrite"`, `"rename"`, or `None` (skip) based on user input.
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
    }
}

/// Options for the `sync` command.
pub struct SyncOptions {
    pub library: Option<String>,
    pub servers: bool,
    pub push_only: bool,
    pub pull_only: bool,
    pub dry_run: bool,
}

/// Runs the sync command with the given options.
///
/// Supports listing servers, push-only, pull-only, bidirectional, and dry-run modes.
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...");
            // Respect config direction when no CLI flags are provided
            // When both push and pull are effective (bidirectional), pass neither
            // so run_sync defaults to Bidirectional instead of Push-only.
            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() {
        // link_server_library derives filenames by lowercasing and replacing spaces.
        // The function takes a &mut LibraryManager, but the slug transformation
        // is the testable contract — verify the expected mapping directly.
        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);
    }
}