1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result, bail};
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::path::Path;
7
8use crate::api::RedashClient;
9use crate::models::{CreateQuerySnippet, QuerySnippet, SnippetMetadata};
10
11fn find_snippet_files_in(snippets_dir: &Path, snippet_id: u64) -> Result<Option<(String, String)>> {
12 if !snippets_dir.exists() {
13 return Ok(None);
14 }
15
16 let mut sql_path = None;
17 let mut yaml_path = None;
18
19 for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
20 let entry = entry.context("Failed to read directory entry")?;
21 let path = entry.path();
22
23 if let Some(filename) = path.file_name().and_then(|f| f.to_str())
24 && let Some(id_str) = filename.split('-').next()
25 && let Ok(id) = id_str.parse::<u64>()
26 && id == snippet_id
27 {
28 if path.extension().is_some_and(|ext| ext == "sql") {
29 sql_path = Some(path.to_string_lossy().to_string());
30 } else if path.extension().is_some_and(|ext| ext == "yaml") {
31 yaml_path = Some(path.to_string_lossy().to_string());
32 }
33 }
34 }
35
36 match (sql_path, yaml_path) {
37 (Some(sql), Some(yaml)) => Ok(Some((sql, yaml))),
38 _ => Ok(None),
39 }
40}
41
42fn find_snippet_files(snippet_id: u64) -> Result<Option<(String, String)>> {
43 find_snippet_files_in(Path::new("snippets"), snippet_id)
44}
45
46fn extract_snippet_ids_from_path(snippets_dir: &Path) -> Result<Vec<u64>> {
47 if !snippets_dir.exists() {
48 return Ok(Vec::new());
49 }
50
51 let mut snippet_ids = Vec::new();
52
53 for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
54 let entry = entry.context("Failed to read directory entry")?;
55 let path = entry.path();
56
57 if path.extension().is_some_and(|ext| ext == "yaml")
58 && let Some(filename) = path.file_name().and_then(|f| f.to_str())
59 && let Some(id_str) = filename.split('-').next()
60 && let Ok(id) = id_str.parse::<u64>()
61 {
62 snippet_ids.push(id);
63 }
64 }
65
66 snippet_ids.sort_unstable();
67 snippet_ids.dedup();
68
69 Ok(snippet_ids)
70}
71
72fn extract_snippet_ids_from_directory() -> Result<Vec<u64>> {
73 extract_snippet_ids_from_path(Path::new("snippets"))
74}
75
76fn bail_on_duplicate_ids(paths_by_id: &HashMap<u64, Vec<String>>) -> Result<()> {
77 let mut conflicts: Vec<_> = paths_by_id
78 .iter()
79 .filter(|(_, paths)| paths.len() > 1)
80 .collect();
81
82 if conflicts.is_empty() {
83 return Ok(());
84 }
85
86 conflicts.sort_by_key(|(id, _)| **id);
87 let details: Vec<String> = conflicts
88 .into_iter()
89 .map(|(id, paths)| {
90 let mut paths = paths.clone();
91 paths.sort();
92 format!(" id {id}: {}", paths.join(", "))
93 })
94 .collect();
95
96 bail!(
97 "Multiple local files claim the same id — resolve the conflict before deploying:\n{}",
98 details.join("\n")
99 );
100}
101
102fn get_all_snippet_metadata_from_path(snippets_dir: &Path) -> Result<Vec<(u64, String)>> {
103 if !snippets_dir.exists() {
104 bail!("snippets directory not found. Run 'stmo-cli snippets fetch' first.");
105 }
106
107 let mut snippets = Vec::new();
108 let mut paths_by_id: HashMap<u64, Vec<String>> = HashMap::new();
109
110 for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
111 let entry = entry.context("Failed to read directory entry")?;
112 let path = entry.path();
113
114 if path.extension().is_some_and(|ext| ext == "yaml") {
115 let metadata_content =
116 fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
117
118 let metadata: SnippetMetadata = serde_yaml::from_str(&metadata_content)
119 .context(format!("Failed to parse {}", path.display()))?;
120
121 paths_by_id
122 .entry(metadata.id)
123 .or_default()
124 .push(path.display().to_string());
125 snippets.push((metadata.id, metadata.trigger));
126 }
127 }
128
129 bail_on_duplicate_ids(&paths_by_id)?;
130
131 snippets.sort_by_key(|(id, _)| *id);
132
133 Ok(snippets)
134}
135
136fn get_all_snippet_metadata() -> Result<Vec<(u64, String)>> {
137 get_all_snippet_metadata_from_path(Path::new("snippets"))
138}
139
140fn snippet_differs(
144 local_body: &str,
145 local_metadata: &SnippetMetadata,
146 server: &QuerySnippet,
147) -> bool {
148 local_body != server.snippet
149 || local_metadata.trigger != server.trigger
150 || local_metadata.description != server.description
151}
152
153fn read_local_snippet(id: u64, trigger: &str) -> Result<(String, SnippetMetadata)> {
154 let sql_path = format!("snippets/{id}-{trigger}.sql");
155 let yaml_path = format!("snippets/{id}-{trigger}.yaml");
156
157 let body = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
158 let metadata_content =
159 fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
160 let metadata: SnippetMetadata =
161 serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
162
163 Ok((body, metadata))
164}
165
166async fn find_changed_snippets(
173 client: &RedashClient,
174 all_snippets: &[(u64, String)],
175) -> Result<HashSet<u64>> {
176 let server_snippets = client.list_query_snippets().await?;
177
178 let mut changed_ids = HashSet::new();
179
180 for (id, trigger) in all_snippets {
181 let id = *id;
182 if id == 0 {
183 changed_ids.insert(id);
184 continue;
185 }
186
187 let Some(server) = server_snippets.iter().find(|s| s.id == id) else {
188 eprintln!(" ⚠ Skipping snippet {id}: not found on the server");
189 continue;
190 };
191
192 match read_local_snippet(id, trigger) {
193 Ok((body, metadata)) => {
194 if snippet_differs(&body, &metadata, server) {
195 changed_ids.insert(id);
196 }
197 }
198 Err(e) => eprintln!(" ⚠ Skipping snippet {id}: {e}"),
199 }
200 }
201
202 Ok(changed_ids)
203}
204
205fn write_snippet_files(snippet: &QuerySnippet) -> Result<()> {
206 fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
207
208 let filename_base = format!("{}-{}", snippet.id, snippet.trigger);
209
210 let sql_path = format!("snippets/{filename_base}.sql");
211 fs::write(&sql_path, &snippet.snippet).context(format!("Failed to write {sql_path}"))?;
212
213 let metadata = SnippetMetadata {
214 id: snippet.id,
215 trigger: snippet.trigger.clone(),
216 description: snippet.description.clone(),
217 };
218 let yaml_path = format!("snippets/{filename_base}.yaml");
219 let yaml_content =
220 serde_yaml::to_string(&metadata).context("Failed to serialize snippet metadata")?;
221 fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
222
223 Ok(())
224}
225
226fn delete_snippet_files(sql_path: &str, yaml_path: &str) -> Result<()> {
227 fs::remove_file(sql_path).context(format!("Failed to delete {sql_path}"))?;
228 fs::remove_file(yaml_path).context(format!("Failed to delete {yaml_path}"))?;
229 Ok(())
230}
231
232pub async fn list(client: &RedashClient) -> Result<()> {
233 let mut snippets = client.list_query_snippets().await?;
234 snippets.sort_by_key(|s| s.id);
235
236 println!("=== QUERY SNIPPETS ({}) ===\n", snippets.len());
237 for snippet in &snippets {
238 let desc = snippet.description.as_deref().unwrap_or("");
239 println!(" {} - {}", snippet.id, snippet.trigger);
240 if !desc.is_empty() {
241 println!(" {desc}");
242 }
243 }
244
245 Ok(())
246}
247
248pub async fn fetch(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
249 fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
250
251 let snippets_to_fetch = if all {
252 let existing_ids = extract_snippet_ids_from_directory()?;
253 if existing_ids.is_empty() {
254 bail!(
255 "No snippets found in snippets/ directory. Use specific snippet IDs or run 'snippets list' to see available snippets."
256 );
257 }
258 println!(
259 "Fetching {} snippets from local directory...\n",
260 existing_ids.len()
261 );
262 let mut fetched = Vec::new();
263 for id in &existing_ids {
264 match client.get_query_snippet(*id).await {
265 Ok(snippet) => fetched.push(snippet),
266 Err(e) => eprintln!(" ⚠ Snippet {id} failed to fetch: {e}"),
267 }
268 }
269 fetched
270 } else if !snippet_ids.is_empty() {
271 println!("Fetching {} specific snippets...\n", snippet_ids.len());
272 let mut fetched = Vec::new();
273 for id in &snippet_ids {
274 match client.get_query_snippet(*id).await {
275 Ok(snippet) => fetched.push(snippet),
276 Err(e) => eprintln!(" ⚠ Snippet {id} failed to fetch: {e}"),
277 }
278 }
279 fetched
280 } else {
281 bail!(
282 "No snippet IDs specified. Use --all to fetch tracked snippets, or provide specific snippet IDs.\n\nExamples:\n stmo-cli snippets fetch --all\n stmo-cli snippets fetch 31\n stmo-cli snippets list (to see available snippets)"
283 );
284 };
285
286 println!("Fetching {} snippets...", snippets_to_fetch.len());
287
288 for snippet in &snippets_to_fetch {
289 write_snippet_files(snippet)?;
290 println!(" ✓ {} - {}", snippet.id, snippet.trigger);
291 }
292
293 println!("\n✓ All snippets fetched successfully");
294
295 Ok(())
296}
297
298pub async fn deploy_one(client: &RedashClient, id: u64, trigger: &str) -> Result<QuerySnippet> {
299 let sql_path = format!("snippets/{id}-{trigger}.sql");
300 let yaml_path = format!("snippets/{id}-{trigger}.yaml");
301
302 if !Path::new(&sql_path).exists() {
303 bail!("Snippet SQL file not found: {sql_path}");
304 }
305 if !Path::new(&yaml_path).exists() {
306 bail!("Snippet metadata file not found: {yaml_path}");
307 }
308
309 let body = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
310
311 let metadata_content =
312 fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
313
314 let metadata: SnippetMetadata =
315 serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
316
317 let result = if id == 0 {
318 let create = CreateQuerySnippet {
319 trigger: metadata.trigger.clone(),
320 description: metadata.description.clone(),
321 snippet: body,
322 };
323 let created = client.create_query_snippet(&create).await?;
324 write_snippet_files(&created)?;
325 fs::remove_file(&sql_path).context(format!("Failed to delete {sql_path}"))?;
326 fs::remove_file(&yaml_path).context(format!("Failed to delete {yaml_path}"))?;
327 println!(
328 " ✓ Created new snippet: {} - {}",
329 created.id, created.trigger
330 );
331 println!(
332 " Renamed: 0-{trigger}.* → {}-{}.*",
333 created.id, created.trigger
334 );
335 created
336 } else {
337 let snippet = QuerySnippet {
338 id,
339 trigger: metadata.trigger.clone(),
340 description: metadata.description.clone(),
341 snippet: body,
342 user: None,
343 updated_at: String::new(),
344 created_at: String::new(),
345 };
346 let updated = client.update_query_snippet(&snippet).await?;
347 write_snippet_files(&updated)?;
348 println!(" ✓ {id} - {}", updated.trigger);
349 updated
350 };
351
352 Ok(result)
353}
354
355pub async fn deploy(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
356 let all_snippets = get_all_snippet_metadata()?;
357
358 let snippets_to_deploy = if !snippet_ids.is_empty() {
359 let ids_set: HashSet<_> = snippet_ids.iter().copied().collect();
360 let filtered: Vec<_> = all_snippets
361 .into_iter()
362 .filter(|(id, _)| ids_set.contains(id))
363 .collect();
364
365 if filtered.is_empty() {
366 bail!("None of the specified snippet IDs were found in snippets/ directory");
367 }
368
369 println!("Deploying {} specific snippets...", filtered.len());
370 for (id, trigger) in &filtered {
371 println!(" → {id} - {trigger}");
372 }
373 println!();
374
375 filtered
376 } else if all {
377 println!("Deploying all {} snippets...\n", all_snippets.len());
378 all_snippets
379 } else {
380 let changed_ids = find_changed_snippets(client, &all_snippets).await?;
381
382 if changed_ids.is_empty() {
383 println!("No changed snippets detected.");
384 println!("Tip: Use --all to deploy all snippets regardless of differences.");
385 return Ok(());
386 }
387
388 let filtered: Vec<_> = all_snippets
389 .into_iter()
390 .filter(|(id, _)| changed_ids.contains(id))
391 .collect();
392
393 println!("Deploying {} changed snippets...", filtered.len());
394 for (id, trigger) in &filtered {
395 println!(" → {id} - {trigger}");
396 }
397 println!();
398
399 filtered
400 };
401
402 for (id, trigger) in &snippets_to_deploy {
403 deploy_one(client, *id, trigger).await?;
404 }
405
406 println!("\n✓ All snippets deployed successfully");
407
408 Ok(())
409}
410
411pub async fn delete(client: &RedashClient, snippet_ids: Vec<u64>) -> Result<()> {
412 let mut errors = Vec::new();
413 let mut deleted_count = 0;
414
415 println!("Deleting {} query snippets...\n", snippet_ids.len());
416
417 for snippet_id in &snippet_ids {
418 match client.delete_query_snippet(*snippet_id).await {
419 Ok(()) => {
420 println!(" ✓ Deleted snippet {snippet_id}");
421
422 if let Ok(Some((sql_path, yaml_path))) = find_snippet_files(*snippet_id) {
423 if let Err(e) = delete_snippet_files(&sql_path, &yaml_path) {
424 eprintln!(" ⚠ Failed to delete local files for snippet {snippet_id}: {e}");
425 } else {
426 println!(" Deleted local files");
427 }
428 } else {
429 println!(" No local files found");
430 }
431
432 deleted_count += 1;
433 }
434 Err(e) => {
435 eprintln!(" ✗ Failed to delete snippet {snippet_id}: {e}");
436 errors.push((*snippet_id, e));
437 }
438 }
439 }
440
441 println!(
442 "\n✓ Deleted {deleted_count}/{} query snippets",
443 snippet_ids.len()
444 );
445
446 if !errors.is_empty() {
447 anyhow::bail!("Failed to delete {} query snippets", errors.len());
448 }
449
450 Ok(())
451}
452
453#[cfg(test)]
454#[allow(clippy::missing_errors_doc)]
455mod tests {
456 use super::*;
457 use tempfile::TempDir;
458
459 #[test]
460 fn test_extract_snippet_ids_from_path_empty() {
461 let temp_dir = TempDir::new().unwrap();
462 let result = extract_snippet_ids_from_path(temp_dir.path());
463 assert!(result.is_ok());
464 assert!(result.unwrap().is_empty());
465 }
466
467 #[test]
468 fn test_extract_snippet_ids_from_path_missing_directory() {
469 let temp_dir = TempDir::new().unwrap();
470 let missing = temp_dir.path().join("does-not-exist");
471 let result = extract_snippet_ids_from_path(&missing);
472 assert!(result.is_ok());
473 assert!(result.unwrap().is_empty());
474 }
475
476 #[test]
477 fn test_extract_snippet_ids_from_path_deduplication() {
478 let temp_dir = TempDir::new().unwrap();
479 let dir = temp_dir.path();
480
481 fs::write(dir.join("31-old_trigger_name.yaml"), "test").unwrap();
482 fs::write(dir.join("31-new_trigger_name.yaml"), "test").unwrap();
483
484 let ids = extract_snippet_ids_from_path(dir).unwrap();
485 assert_eq!(ids, vec![31]);
486 }
487
488 #[test]
489 fn test_extract_snippet_ids_from_path_ignores_non_yaml() {
490 let temp_dir = TempDir::new().unwrap();
491 let dir = temp_dir.path();
492
493 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
494 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "test").unwrap();
495 fs::write(dir.join("README.md"), "test").unwrap();
496
497 let ids = extract_snippet_ids_from_path(dir).unwrap();
498 assert_eq!(ids, vec![31]);
499 }
500
501 #[test]
502 fn test_extract_snippet_ids_from_path_ignores_id_without_separator() {
503 let temp_dir = TempDir::new().unwrap();
504 let dir = temp_dir.path();
505
506 fs::write(dir.join("31.yaml"), "test").unwrap();
509 fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
510
511 let ids = extract_snippet_ids_from_path(dir).unwrap();
512 assert_eq!(ids, vec![42]);
513 }
514
515 #[test]
516 fn test_extract_snippet_ids_from_path_includes_id_zero() {
517 let temp_dir = TempDir::new().unwrap();
518 let dir = temp_dir.path();
519
520 fs::write(dir.join("0-stmo_cli_selftest.yaml"), "test").unwrap();
523
524 let ids = extract_snippet_ids_from_path(dir).unwrap();
525 assert_eq!(ids, vec![0]);
526 }
527
528 #[test]
529 fn test_extract_snippet_ids_from_path_sorted() {
530 let temp_dir = TempDir::new().unwrap();
531 let dir = temp_dir.path();
532
533 fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
534 fs::write(dir.join("9-hll_convert.yaml"), "test").unwrap();
535 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
536
537 let ids = extract_snippet_ids_from_path(dir).unwrap();
538 assert_eq!(ids, vec![9, 31, 42]);
539 }
540
541 #[test]
542 fn test_find_snippet_files_in_found() {
543 let temp_dir = TempDir::new().unwrap();
544 let dir = temp_dir.path();
545
546 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
547 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
548
549 let result = find_snippet_files_in(dir, 31).unwrap();
550 assert!(result.is_some());
551 let (sql_path, yaml_path) = result.unwrap();
552 assert_eq!(
553 Path::new(&sql_path).extension(),
554 Some(std::ffi::OsStr::new("sql"))
555 );
556 assert_eq!(
557 Path::new(&yaml_path).extension(),
558 Some(std::ffi::OsStr::new("yaml"))
559 );
560 }
561
562 #[test]
563 fn test_find_snippet_files_in_no_matching_id() {
564 let temp_dir = TempDir::new().unwrap();
565 let dir = temp_dir.path();
566
567 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
568 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
569
570 let result = find_snippet_files_in(dir, 99).unwrap();
571 assert!(result.is_none());
572 }
573
574 #[test]
575 fn test_find_snippet_files_in_missing_yaml() {
576 let temp_dir = TempDir::new().unwrap();
577 let dir = temp_dir.path();
578
579 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
580
581 let result = find_snippet_files_in(dir, 31).unwrap();
582 assert!(result.is_none());
583 }
584
585 #[test]
586 fn test_find_snippet_files_in_missing_directory() {
587 let temp_dir = TempDir::new().unwrap();
588 let missing = temp_dir.path().join("does-not-exist");
589
590 let result = find_snippet_files_in(&missing, 31).unwrap();
591 assert!(result.is_none());
592 }
593
594 #[test]
595 fn test_find_snippet_files_in_id_zero() {
596 let temp_dir = TempDir::new().unwrap();
597 let dir = temp_dir.path();
598
599 fs::write(dir.join("0-stmo_cli_selftest.sql"), "SELECT 1").unwrap();
602 fs::write(dir.join("0-stmo_cli_selftest.yaml"), "id: 0").unwrap();
603
604 let result = find_snippet_files_in(dir, 0).unwrap();
605 assert!(result.is_some());
606 }
607
608 #[test]
609 fn test_find_snippet_files_in_missing_sql() {
610 let temp_dir = TempDir::new().unwrap();
611 let dir = temp_dir.path();
612
613 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
614
615 let result = find_snippet_files_in(dir, 31).unwrap();
616 assert!(result.is_none());
617 }
618
619 #[test]
620 fn test_find_snippet_files_in_exact_id_match_not_prefix() {
621 let temp_dir = TempDir::new().unwrap();
622 let dir = temp_dir.path();
623
624 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
625 fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
626
627 let result = find_snippet_files_in(dir, 3).unwrap();
629 assert!(result.is_none());
630 }
631
632 #[test]
633 fn test_get_all_snippet_metadata_from_path_basic() {
634 let temp_dir = TempDir::new().unwrap();
635 let dir = temp_dir.path();
636
637 fs::write(
638 dir.join("31-reviewbot_e2e_action_ctcs.yaml"),
639 "id: 31\ntrigger: reviewbot_e2e_action_ctcs\ndescription: null\n",
640 )
641 .unwrap();
642
643 let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
644 assert_eq!(
645 metadata,
646 vec![(31, "reviewbot_e2e_action_ctcs".to_string())]
647 );
648 }
649
650 #[test]
651 fn test_get_all_snippet_metadata_from_path_sorted_by_id() {
652 let temp_dir = TempDir::new().unwrap();
653 let dir = temp_dir.path();
654
655 fs::write(
656 dir.join("42-zebra.yaml"),
657 "id: 42\ntrigger: zebra\ndescription: null\n",
658 )
659 .unwrap();
660 fs::write(
661 dir.join("9-hll_convert.yaml"),
662 "id: 9\ntrigger: hll_convert\ndescription: null\n",
663 )
664 .unwrap();
665
666 let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
667 assert_eq!(
668 metadata,
669 vec![(9, "hll_convert".to_string()), (42, "zebra".to_string())]
670 );
671 }
672
673 #[test]
674 fn test_get_all_snippet_metadata_from_path_missing_directory_errors() {
675 let temp_dir = TempDir::new().unwrap();
676 let missing = temp_dir.path().join("does-not-exist");
677
678 let result = get_all_snippet_metadata_from_path(&missing);
679 assert!(result.is_err());
680 assert!(
681 result
682 .unwrap_err()
683 .to_string()
684 .contains("snippets directory not found")
685 );
686 }
687
688 #[test]
689 fn test_get_all_snippet_metadata_from_path_empty_directory() {
690 let temp_dir = TempDir::new().unwrap();
691
692 let metadata = get_all_snippet_metadata_from_path(temp_dir.path()).unwrap();
693 assert!(metadata.is_empty());
694 }
695
696 #[test]
697 fn test_get_all_snippet_metadata_from_path_malformed_yaml_errors() {
698 let temp_dir = TempDir::new().unwrap();
699 let dir = temp_dir.path();
700
701 fs::write(
702 dir.join("31-broken.yaml"),
703 "description: missing required fields\n",
704 )
705 .unwrap();
706
707 let result = get_all_snippet_metadata_from_path(dir);
708 assert!(result.is_err());
709 assert!(result.unwrap_err().to_string().contains("Failed to parse"));
710 }
711
712 #[test]
713 fn test_get_all_snippet_metadata_from_path_rejects_duplicate_ids() {
714 let temp_dir = TempDir::new().unwrap();
715 let dir = temp_dir.path();
716
717 fs::write(
718 dir.join("120506-first-trigger.yaml"),
719 "id: 120506\ntrigger: first_trigger\ndescription: null\n",
720 )
721 .unwrap();
722 fs::write(
723 dir.join("120506-second-trigger.yaml"),
724 "id: 120506\ntrigger: second_trigger\ndescription: null\n",
725 )
726 .unwrap();
727
728 let result = get_all_snippet_metadata_from_path(dir);
729 assert!(result.is_err());
730 let err_msg = result.unwrap_err().to_string();
731 assert!(err_msg.contains("Multiple local files claim the same id"));
732 assert!(err_msg.contains("id 120506"));
733 assert!(err_msg.contains("120506-first-trigger.yaml"));
734 assert!(err_msg.contains("120506-second-trigger.yaml"));
735 }
736
737 #[test]
738 fn test_snippet_differs_false_when_identical() {
739 let metadata = SnippetMetadata {
740 id: 1,
741 trigger: "t".to_string(),
742 description: None,
743 };
744 let server = make_server_snippet(1, "t", "SELECT 1", None);
745 assert!(!snippet_differs("SELECT 1", &metadata, &server));
746 }
747
748 #[test]
749 fn test_snippet_differs_true_when_body_differs() {
750 let metadata = SnippetMetadata {
751 id: 1,
752 trigger: "t".to_string(),
753 description: None,
754 };
755 let server = make_server_snippet(1, "t", "SELECT 1", None);
756 assert!(snippet_differs("SELECT 2", &metadata, &server));
757 }
758
759 #[test]
760 fn test_snippet_differs_true_when_trigger_differs() {
761 let metadata = SnippetMetadata {
762 id: 1,
763 trigger: "local_trigger".to_string(),
764 description: None,
765 };
766 let server = make_server_snippet(1, "server_trigger", "SELECT 1", None);
767 assert!(snippet_differs("SELECT 1", &metadata, &server));
768 }
769
770 #[test]
771 fn test_snippet_differs_true_when_description_differs() {
772 let metadata = SnippetMetadata {
773 id: 1,
774 trigger: "t".to_string(),
775 description: Some("local".to_string()),
776 };
777 let server = make_server_snippet(1, "t", "SELECT 1", Some("server".to_string()));
778 assert!(snippet_differs("SELECT 1", &metadata, &server));
779 }
780
781 fn make_server_snippet(
782 id: u64,
783 trigger: &str,
784 snippet: &str,
785 description: Option<String>,
786 ) -> QuerySnippet {
787 QuerySnippet {
788 id,
789 trigger: trigger.to_string(),
790 description,
791 snippet: snippet.to_string(),
792 user: None,
793 updated_at: String::new(),
794 created_at: String::new(),
795 }
796 }
797}