Skip to main content

stmo_cli/commands/
snippets.rs

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
140// Shared with `deploy` — compares everything `deploy_one` actually pushes to
141// Redash against what's already there, so "changed" means "differs from the
142// server", not "differs from git's working tree".
143fn 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
166// Snippets are cheap: Redash returns every snippet's full body in one list
167// call, so unlike `deploy::find_changed_queries` this needs no per-snippet
168// GET and no concurrency — just one request, then a local comparison per
169// tracked id. A snippet that fails to compare (deleted server-side,
170// unreadable local files, ...) is skipped with a warning rather than
171// aborting the whole run.
172async 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        // "31.yaml" has no '-' separator, so the whole stem fails to parse as a u64
507        // and must be silently skipped, not mistaken for id 31.
508        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        // id 0 is the sentinel for "not yet created" mid-deploy; it must still be
521        // discovered like any other id, not treated as absent/falsy.
522        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        // id 0 is the sentinel used for "not yet created" mid-deploy, before the
600        // server assigns a real id and the files get renamed.
601        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        // Searching for id 3 must not match a file whose id (31) merely starts with "3".
628        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}