Skip to main content

snip_it/commands/
mod.rs

1//! CLI command implementations.
2//!
3//! Each subcommand in the CLI has its own module with a `run()` function.
4//! This module also provides shared utilities for loading and saving snippets.
5
6pub mod clip_cmd;
7pub mod cron_cmd;
8pub mod edit_cmd;
9pub mod keybindings_cmd;
10pub mod library_cmd;
11pub mod list_cmd;
12pub mod new_cmd;
13pub mod premade_cmd;
14pub mod register_cmd;
15pub mod run_cmd;
16pub mod search_cmd;
17pub mod sync_cmd;
18
19use crate::config::invalidate_toml_cache;
20use crate::error::{SnipError, SnipResult};
21use crate::utils::toml_helpers::{fix_invalid_toml_escapes, quote_strings_containing_backslashes};
22use std::path::PathBuf;
23
24/// Result of expanding a snippet command with variables.
25pub enum ExpandedCommand {
26    /// User cancelled the operation.
27    Cancel,
28    /// User chose to skip (continue to next snippet).
29    Skip,
30    /// Command was expanded successfully.
31    Expanded(String),
32}
33
34/// Resolves the config path from CLI argument or default location.
35pub fn get_config_path(config: &Option<PathBuf>) -> SnipResult<PathBuf> {
36    use std::fs::{self, OpenOptions};
37    use std::io::ErrorKind;
38
39    match config {
40        Some(path) => {
41            if path.is_file() {
42                Ok(path.clone())
43            } else if path.exists() {
44                Err(SnipError::runtime_error(
45                    "Config path is not a file",
46                    Some(&format!(
47                        "'{}' exists but is not a regular file",
48                        path.display()
49                    )),
50                ))
51            } else {
52                if let Some(parent) = path.parent() {
53                    fs::create_dir_all(parent)
54                        .map_err(|e| SnipError::io_error("create directory", parent, e))?;
55                }
56                match OpenOptions::new().write(true).create_new(true).open(path) {
57                    Ok(_) => {}
58                    Err(e) if e.kind() == ErrorKind::AlreadyExists && path.is_file() => {}
59                    Err(e) => {
60                        return Err(SnipError::io_error("create config file", path.clone(), e));
61                    }
62                }
63                Ok(path.clone())
64            }
65        }
66        None => Ok(crate::utils::config::get_snippets_path()),
67    }
68}
69
70/// Resolves the path to a named library or returns the primary library path.
71pub fn get_library_path(library_name: Option<String>) -> SnipResult<Option<PathBuf>> {
72    use crate::library::LibraryManager;
73
74    let mut mgr = LibraryManager::new()?;
75
76    mgr.ensure_library_mode()?;
77
78    let path = match library_name {
79        Some(name) => {
80            let lib = mgr.get_library_by_filename(&name)
81                .ok_or_else(|| SnipError::runtime_error(
82                    "Library not found",
83                    Some(&format!("Library '{name}' does not exist. Use 'snp library list' to see available libraries.")),
84                ))?;
85            Some(
86                mgr.get_libraries_dir()
87                    .join(format!("{}.toml", lib.filename)),
88            )
89        }
90        None => mgr.get_primary_library().map(|primary| {
91            mgr.get_libraries_dir()
92                .join(format!("{}.toml", primary.filename))
93        }),
94    };
95
96    Ok(path)
97}
98
99/// Initializes a LibraryManager with library mode enabled, handling errors gracefully.
100pub fn init_library_manager() -> SnipResult<crate::library::LibraryManager> {
101    let mut mgr = crate::library::LibraryManager::new()?;
102    mgr.ensure_library_mode()?;
103    Ok(mgr)
104}
105
106/// Loads snippets from a TOML file, returning an empty collection if the file doesn't exist.
107pub fn load_snippets(config: &Option<PathBuf>) -> SnipResult<crate::library::Snippets> {
108    use std::fs;
109
110    let path = get_config_path(config)?;
111
112    if !path.exists() {
113        crate::logging::log_config_operation("load", &path, &Err("file not found"));
114        return Ok(crate::library::Snippets::default());
115    }
116
117    let content = fs::read_to_string(&path).map_err(|e| {
118        crate::logging::log_config_operation("load", &path, &Err(&e.to_string()));
119        SnipError::io_error("read snippets file", path.clone(), e)
120    })?;
121
122    if content.is_empty() || content.trim().is_empty() {
123        crate::logging::log_config_operation("load", &path, &Ok(()));
124        return Ok(crate::library::Snippets::default());
125    }
126
127    let fixed_content = fix_invalid_toml_escapes(&content);
128
129    let snippets: crate::library::Snippets = toml::from_str(&fixed_content).map_err(|e| {
130        crate::logging::log_config_operation("parse", &path, &Err(&e.to_string()));
131        let backup_path = path.with_extension("toml.bak");
132        if let Err(backup_err) = std::fs::copy(&path, &backup_path) {
133            tracing::warn!(
134                error = %e,
135                backup_error = %backup_err,
136                "Failed to parse config and could not create backup"
137            );
138        } else {
139            tracing::warn!(
140                backup = %backup_path.display(),
141                "Failed to parse config file. Backup saved."
142            );
143        }
144        SnipError::toml_error("parse snippets file", e)
145    })?;
146
147    crate::logging::log_config_operation("load", &path, &Ok(()));
148
149    Ok(snippets)
150}
151
152/// Saves snippets to a TOML file, creating directories as needed.
153///
154/// Uses atomic write (temp file + rename) and creates a backup before saving,
155/// matching the safety guarantees of `save_library`.
156pub fn save_snippets(s: &crate::library::Snippets, config: &Option<PathBuf>) -> SnipResult<()> {
157    let path = get_config_path(config)?;
158
159    if let Err(e) = crate::library::backup_library(&path) {
160        tracing::warn!(error = %e, "Failed to create backup before save");
161    }
162
163    let toml_str =
164        toml::to_string_pretty(s).map_err(|e| SnipError::toml_error("serialize config", e))?;
165
166    let toml_str = quote_strings_containing_backslashes(&toml_str);
167
168    let temp_prefix = path
169        .file_stem()
170        .and_then(|s| s.to_str())
171        .unwrap_or("config");
172    crate::utils::atomic::write_private_atomic(&path, &toml_str, temp_prefix)?;
173
174    invalidate_toml_cache(&path);
175
176    crate::logging::log_config_operation("save", &path, &Ok(()));
177    Ok(())
178}
179
180/// Extracts snippet data arrays for TUI display.
181///
182/// Returns parallel arrays of descriptions, commands, tags, folders, and favorites,
183/// along with a mapping from filtered indices to original snippet indices.
184pub fn get_snippet_data(snippets: &crate::library::Snippets) -> (crate::SnippetData, Vec<usize>) {
185    let filtered: Vec<_> = snippets
186        .snippets
187        .iter()
188        .enumerate()
189        .filter(|(_, s)| !s.deleted)
190        .collect();
191    let original_indices: Vec<usize> = filtered.iter().map(|(i, _)| *i).collect();
192    let descriptions: Vec<String> = filtered
193        .iter()
194        .map(|(_, s)| s.description.clone())
195        .collect();
196    let commands: Vec<String> = filtered.iter().map(|(_, s)| s.command.clone()).collect();
197    let tags: Vec<Vec<String>> = filtered.iter().map(|(_, s)| s.tags.clone()).collect();
198    let folders: Vec<Vec<String>> = filtered.iter().map(|(_, s)| s.folders.clone()).collect();
199    let favorites: Vec<bool> = filtered.iter().map(|(_, s)| s.favorite).collect();
200    (
201        crate::SnippetData {
202            descriptions,
203            commands,
204            tags,
205            folders,
206            favorites,
207        },
208        original_indices,
209    )
210}
211
212/// Expands a snippet command, prompting for variables if present.
213pub fn expand_snippet_command(snippet: &crate::library::Snippet) -> SnipResult<ExpandedCommand> {
214    use crate::ui;
215    use crate::utils::{parse_variables, strip_escape_sequences};
216
217    let vars = parse_variables(&snippet.command);
218    if vars.is_empty() {
219        return Ok(ExpandedCommand::Expanded(strip_escape_sequences(
220            &snippet.command,
221        )));
222    }
223
224    match ui::prompt_variables(vars)? {
225        ui::VariablePromptResult::Cancel => Ok(ExpandedCommand::Cancel),
226        ui::VariablePromptResult::Back => Ok(ExpandedCommand::Skip),
227        ui::VariablePromptResult::Skip => Ok(ExpandedCommand::Skip),
228        ui::VariablePromptResult::Values(values) => Ok(ExpandedCommand::Expanded(
229            crate::utils::expand_command(&snippet.command, &values),
230        )),
231    }
232}
233
234/// Opens the TUI snippet selector and runs the given processing function on selection.
235///
236/// Handles loading the library, extracting snippet data, and optionally running
237/// a background sync after processing. The `process_fn` callback is invoked with
238/// the selected snippet and any copy flag from the TUI.
239pub fn run_snippet_selection<F>(
240    filter: Option<String>,
241    library: Option<String>,
242    do_sync: bool,
243    runtime: &tokio::runtime::Runtime,
244    mut process_fn: F,
245) -> crate::error::SnipResult<()>
246where
247    F: FnMut(
248        &crate::library::Snippet,
249        Option<String>,
250    ) -> crate::error::SnipResult<crate::ProcessResult>,
251{
252    let lib_path = match get_library_path(library)? {
253        Some(p) => p,
254        None => {
255            eprintln!("No library found. Create one with 'snp library create <name>'");
256            return Ok(());
257        }
258    };
259    let mut snippets = crate::library::load_library(&lib_path)?;
260
261    let mut selected_and_processed = false;
262    loop {
263        let (snippet_data, original_indices) = get_snippet_data(&snippets);
264        let result = crate::ui::select_snippet(crate::ui::SnippetListParams {
265            descriptions: &snippet_data.descriptions,
266            commands: &snippet_data.commands,
267            tags: &snippet_data.tags,
268            is_search: false,
269            initial_filter: filter.as_deref(),
270            folders: &snippet_data.folders,
271            favorites: &snippet_data.favorites,
272            snippets: &snippets.snippets,
273            original_indices: &original_indices,
274        })?;
275        if let Some(result) = result {
276            match result {
277                crate::ui::SnippetSelection::Delete(idx) => {
278                    let original_idx = *original_indices.get(idx).ok_or_else(|| {
279                        SnipError::runtime_error(
280                            "Snippet not found",
281                            Some("The selected snippet is no longer available"),
282                        )
283                    })?;
284                    let deleted_snippet = mark_snippet_deleted(&mut snippets, original_idx)?;
285                    crate::library::save_library(&lib_path, &snippets)?;
286                    if let Err(e) = crate::logging::audit_log("delete", &deleted_snippet, None) {
287                        tracing::debug!("Audit log write failed: {}", e);
288                    }
289                    if do_sync && let Err(e) = crate::sync_commands::run_default_sync(runtime) {
290                        tracing::warn!(error = %e, "Background sync after delete failed");
291                    }
292                    continue;
293                }
294                crate::ui::SnippetSelection::Selected(idx, copy_flag) => {
295                    let snippet = &snippets.snippets[original_indices[idx]];
296                    match process_fn(snippet, copy_flag)? {
297                        crate::ProcessResult::Cancel => {
298                            return Ok(());
299                        }
300                        crate::ProcessResult::Continue => continue,
301                        crate::ProcessResult::Done(_msg) => {
302                            selected_and_processed = true;
303                            break;
304                        }
305                    }
306                }
307            }
308        } else {
309            break;
310        }
311    }
312    if do_sync
313        && selected_and_processed
314        && let Err(e) = crate::sync_commands::run_default_sync(runtime)
315    {
316        tracing::warn!(error = %e, "Background sync failed");
317    }
318    Ok(())
319}
320
321/// Marks a selected snippet as deleted while preserving its tombstone for sync.
322fn mark_snippet_deleted(
323    snippets: &mut crate::library::Snippets,
324    original_idx: usize,
325) -> SnipResult<crate::library::Snippet> {
326    let snippet = snippets.snippets.get_mut(original_idx).ok_or_else(|| {
327        SnipError::runtime_error(
328            "Snippet not found",
329            Some("The selected snippet is no longer available"),
330        )
331    })?;
332
333    snippet.deleted = true;
334    let now = chrono::Utc::now().timestamp();
335    snippet.updated_at = snippet.updated_at.max(now).saturating_add(1);
336    Ok(snippet.clone())
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use tempfile::TempDir;
343
344    #[test]
345    fn test_load_snippets_missing_file() {
346        let tmp = TempDir::new().unwrap();
347        let path = Some(tmp.path().join("nonexistent.toml"));
348        let snippets = load_snippets(&path).unwrap();
349        assert!(snippets.snippets.is_empty());
350    }
351
352    #[test]
353    fn test_load_snippets_empty_file() {
354        let tmp = TempDir::new().unwrap();
355        let path = tmp.path().join("empty.toml");
356        std::fs::write(&path, "").unwrap();
357        let snippets = load_snippets(&Some(path)).unwrap();
358        assert!(snippets.snippets.is_empty());
359    }
360
361    #[test]
362    fn test_load_snippets_valid_toml() {
363        let tmp = TempDir::new().unwrap();
364        let path = tmp.path().join("valid.toml");
365        std::fs::write(
366            &path,
367            r#"[[Snippets]]
368description = "test"
369command = "echo hello"
370"#,
371        )
372        .unwrap();
373        let snippets = load_snippets(&Some(path)).unwrap();
374        assert_eq!(snippets.snippets.len(), 1);
375        assert_eq!(snippets.snippets[0].description, "test");
376        assert_eq!(snippets.snippets[0].command, "echo hello");
377    }
378
379    #[test]
380    fn test_load_snippets_invalid_toml_creates_backup() {
381        let tmp = TempDir::new().unwrap();
382        let path = tmp.path().join("invalid.toml");
383        std::fs::write(&path, "invalid = [toml").unwrap();
384        let backup_path = path.with_extension("toml.bak");
385        let result = load_snippets(&Some(path));
386        assert!(result.is_err());
387        assert!(backup_path.exists());
388    }
389
390    #[test]
391    fn test_get_snippet_data_filters_deleted() {
392        let snippets = crate::library::Snippets {
393            snippets: vec![
394                crate::library::Snippet {
395                    id: "1".to_string(),
396                    description: "active".to_string(),
397                    command: "echo 1".to_string(),
398                    ..Default::default()
399                },
400                crate::library::Snippet {
401                    id: "2".to_string(),
402                    description: "deleted".to_string(),
403                    command: "echo 2".to_string(),
404                    deleted: true,
405                    ..Default::default()
406                },
407                crate::library::Snippet {
408                    id: "3".to_string(),
409                    description: "also active".to_string(),
410                    command: "echo 3".to_string(),
411                    ..Default::default()
412                },
413            ],
414            ..Default::default()
415        };
416        let (data, indices) = get_snippet_data(&snippets);
417        assert_eq!(data.descriptions.len(), 2);
418        assert_eq!(data.descriptions[0], "active");
419        assert_eq!(data.descriptions[1], "also active");
420        assert_eq!(indices, vec![0, 2]);
421    }
422
423    #[test]
424    fn test_mark_snippet_deleted_preserves_tombstone_for_sync() {
425        let mut snippets = crate::library::Snippets {
426            snippets: vec![crate::library::Snippet {
427                id: "1".to_string(),
428                description: "remove me".to_string(),
429                command: "echo remove me".to_string(),
430                updated_at: 10,
431                ..Default::default()
432            }],
433            ..Default::default()
434        };
435
436        let deleted = mark_snippet_deleted(&mut snippets, 0).unwrap();
437
438        assert!(deleted.deleted);
439        assert!(deleted.updated_at > 10);
440        assert!(snippets.snippets[0].deleted);
441        assert_eq!(snippets.snippets[0].command, "echo remove me");
442    }
443}