1pub 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
24pub enum ExpandedCommand {
26 Cancel,
28 Skip,
30 Expanded(String),
32}
33
34pub 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
70pub 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
99pub 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
106pub 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
152pub 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
180pub 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
212pub 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
234pub 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
321fn 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}