Skip to main content

stmo_cli/commands/
snippets.rs

1#![allow(clippy::missing_errors_doc)]
2
3use anyhow::{Context, Result, bail};
4use std::collections::HashSet;
5use std::fs;
6use std::path::Path;
7use std::process::Command;
8
9use crate::api::RedashClient;
10use crate::models::{CreateQuerySnippet, QuerySnippet, SnippetMetadata};
11
12fn find_snippet_files_in(snippets_dir: &Path, snippet_id: u64) -> Result<Option<(String, String)>> {
13    if !snippets_dir.exists() {
14        return Ok(None);
15    }
16
17    let mut sql_path = None;
18    let mut yaml_path = None;
19
20    for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
21        let entry = entry.context("Failed to read directory entry")?;
22        let path = entry.path();
23
24        if let Some(filename) = path.file_name().and_then(|f| f.to_str())
25            && let Some(id_str) = filename.split('-').next()
26            && let Ok(id) = id_str.parse::<u64>()
27            && id == snippet_id
28        {
29            if path.extension().is_some_and(|ext| ext == "sql") {
30                sql_path = Some(path.to_string_lossy().to_string());
31            } else if path.extension().is_some_and(|ext| ext == "yaml") {
32                yaml_path = Some(path.to_string_lossy().to_string());
33            }
34        }
35    }
36
37    match (sql_path, yaml_path) {
38        (Some(sql), Some(yaml)) => Ok(Some((sql, yaml))),
39        _ => Ok(None),
40    }
41}
42
43fn find_snippet_files(snippet_id: u64) -> Result<Option<(String, String)>> {
44    find_snippet_files_in(Path::new("snippets"), snippet_id)
45}
46
47fn extract_snippet_ids_from_path(snippets_dir: &Path) -> Result<Vec<u64>> {
48    if !snippets_dir.exists() {
49        return Ok(Vec::new());
50    }
51
52    let mut snippet_ids = Vec::new();
53
54    for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
55        let entry = entry.context("Failed to read directory entry")?;
56        let path = entry.path();
57
58        if path.extension().is_some_and(|ext| ext == "yaml")
59            && let Some(filename) = path.file_name().and_then(|f| f.to_str())
60            && let Some(id_str) = filename.split('-').next()
61            && let Ok(id) = id_str.parse::<u64>()
62        {
63            snippet_ids.push(id);
64        }
65    }
66
67    snippet_ids.sort_unstable();
68    snippet_ids.dedup();
69
70    Ok(snippet_ids)
71}
72
73fn extract_snippet_ids_from_directory() -> Result<Vec<u64>> {
74    extract_snippet_ids_from_path(Path::new("snippets"))
75}
76
77fn get_all_snippet_metadata_from_path(snippets_dir: &Path) -> Result<Vec<(u64, String)>> {
78    if !snippets_dir.exists() {
79        bail!("snippets directory not found. Run 'stmo-cli snippets fetch' first.");
80    }
81
82    let mut snippets = Vec::new();
83
84    for entry in fs::read_dir(snippets_dir).context("Failed to read snippets directory")? {
85        let entry = entry.context("Failed to read directory entry")?;
86        let path = entry.path();
87
88        if path.extension().is_some_and(|ext| ext == "yaml") {
89            let metadata_content =
90                fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
91
92            let metadata: SnippetMetadata = serde_yaml::from_str(&metadata_content)
93                .context(format!("Failed to parse {}", path.display()))?;
94
95            snippets.push((metadata.id, metadata.trigger));
96        }
97    }
98
99    snippets.sort_by_key(|(id, _)| *id);
100
101    Ok(snippets)
102}
103
104fn get_all_snippet_metadata() -> Result<Vec<(u64, String)>> {
105    get_all_snippet_metadata_from_path(Path::new("snippets"))
106}
107
108fn parse_changed_snippet_ids(porcelain: &str) -> HashSet<u64> {
109    let mut changed_ids = HashSet::new();
110
111    for line in porcelain.lines() {
112        if line.len() < 3 {
113            continue;
114        }
115
116        let raw_path = &line[3..];
117        // Rename entries are formatted as "old/path -> new/path"; the new path is
118        // what matters for deciding which id is currently changed.
119        let file_path = raw_path
120            .rsplit_once(" -> ")
121            .map_or(raw_path, |(_old, new_path)| new_path);
122        let path = Path::new(file_path);
123
124        if file_path.starts_with("snippets/")
125            && path.extension().is_some_and(|ext| {
126                ext.eq_ignore_ascii_case("sql") || ext.eq_ignore_ascii_case("yaml")
127            })
128            && let Some(filename) = file_path.strip_prefix("snippets/")
129            && let Some(id_str) = filename.split('-').next()
130            && let Ok(id) = id_str.parse::<u64>()
131        {
132            changed_ids.insert(id);
133        }
134    }
135
136    changed_ids
137}
138
139fn get_changed_snippet_ids() -> Option<HashSet<u64>> {
140    let output = Command::new("git")
141        .args(["status", "--porcelain"])
142        .output()
143        .ok()?;
144
145    if !output.status.success() {
146        return None;
147    }
148
149    let stdout = String::from_utf8(output.stdout).ok()?;
150
151    Some(parse_changed_snippet_ids(&stdout))
152}
153
154fn write_snippet_files(snippet: &QuerySnippet) -> Result<()> {
155    fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
156
157    let filename_base = format!("{}-{}", snippet.id, snippet.trigger);
158
159    let sql_path = format!("snippets/{filename_base}.sql");
160    fs::write(&sql_path, &snippet.snippet).context(format!("Failed to write {sql_path}"))?;
161
162    let metadata = SnippetMetadata {
163        id: snippet.id,
164        trigger: snippet.trigger.clone(),
165        description: snippet.description.clone(),
166    };
167    let yaml_path = format!("snippets/{filename_base}.yaml");
168    let yaml_content =
169        serde_yaml::to_string(&metadata).context("Failed to serialize snippet metadata")?;
170    fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
171
172    Ok(())
173}
174
175fn delete_snippet_files(sql_path: &str, yaml_path: &str) -> Result<()> {
176    fs::remove_file(sql_path).context(format!("Failed to delete {sql_path}"))?;
177    fs::remove_file(yaml_path).context(format!("Failed to delete {yaml_path}"))?;
178    Ok(())
179}
180
181pub async fn list(client: &RedashClient) -> Result<()> {
182    let mut snippets = client.list_query_snippets().await?;
183    snippets.sort_by_key(|s| s.id);
184
185    println!("=== QUERY SNIPPETS ({}) ===\n", snippets.len());
186    for snippet in &snippets {
187        let desc = snippet.description.as_deref().unwrap_or("");
188        println!("  {} - {}", snippet.id, snippet.trigger);
189        if !desc.is_empty() {
190            println!("    {desc}");
191        }
192    }
193
194    Ok(())
195}
196
197pub async fn fetch(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
198    fs::create_dir_all("snippets").context("Failed to create snippets directory")?;
199
200    let snippets_to_fetch = if all {
201        let existing_ids = extract_snippet_ids_from_directory()?;
202        if existing_ids.is_empty() {
203            bail!(
204                "No snippets found in snippets/ directory. Use specific snippet IDs or run 'snippets list' to see available snippets."
205            );
206        }
207        println!(
208            "Fetching {} snippets from local directory...\n",
209            existing_ids.len()
210        );
211        let mut fetched = Vec::new();
212        for id in &existing_ids {
213            match client.get_query_snippet(*id).await {
214                Ok(snippet) => fetched.push(snippet),
215                Err(e) => eprintln!("  ⚠ Snippet {id} failed to fetch: {e}"),
216            }
217        }
218        fetched
219    } else if !snippet_ids.is_empty() {
220        println!("Fetching {} specific snippets...\n", snippet_ids.len());
221        let mut fetched = Vec::new();
222        for id in &snippet_ids {
223            match client.get_query_snippet(*id).await {
224                Ok(snippet) => fetched.push(snippet),
225                Err(e) => eprintln!("  ⚠ Snippet {id} failed to fetch: {e}"),
226            }
227        }
228        fetched
229    } else {
230        bail!(
231            "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)"
232        );
233    };
234
235    println!("Fetching {} snippets...", snippets_to_fetch.len());
236
237    for snippet in &snippets_to_fetch {
238        write_snippet_files(snippet)?;
239        println!("  ✓ {} - {}", snippet.id, snippet.trigger);
240    }
241
242    println!("\n✓ All snippets fetched successfully");
243
244    Ok(())
245}
246
247pub async fn deploy_one(client: &RedashClient, id: u64, trigger: &str) -> Result<QuerySnippet> {
248    let sql_path = format!("snippets/{id}-{trigger}.sql");
249    let yaml_path = format!("snippets/{id}-{trigger}.yaml");
250
251    if !Path::new(&sql_path).exists() {
252        bail!("Snippet SQL file not found: {sql_path}");
253    }
254    if !Path::new(&yaml_path).exists() {
255        bail!("Snippet metadata file not found: {yaml_path}");
256    }
257
258    let body = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
259
260    let metadata_content =
261        fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
262
263    let metadata: SnippetMetadata =
264        serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
265
266    let result = if id == 0 {
267        let create = CreateQuerySnippet {
268            trigger: metadata.trigger.clone(),
269            description: metadata.description.clone(),
270            snippet: body,
271        };
272        let created = client.create_query_snippet(&create).await?;
273        write_snippet_files(&created)?;
274        fs::remove_file(&sql_path).context(format!("Failed to delete {sql_path}"))?;
275        fs::remove_file(&yaml_path).context(format!("Failed to delete {yaml_path}"))?;
276        println!(
277            "  ✓ Created new snippet: {} - {}",
278            created.id, created.trigger
279        );
280        println!(
281            "    Renamed: 0-{trigger}.* → {}-{}.*",
282            created.id, created.trigger
283        );
284        created
285    } else {
286        let snippet = QuerySnippet {
287            id,
288            trigger: metadata.trigger.clone(),
289            description: metadata.description.clone(),
290            snippet: body,
291            user: None,
292            updated_at: String::new(),
293            created_at: String::new(),
294        };
295        let updated = client.update_query_snippet(&snippet).await?;
296        write_snippet_files(&updated)?;
297        println!("  ✓ {id} - {}", updated.trigger);
298        updated
299    };
300
301    Ok(result)
302}
303
304pub async fn deploy(client: &RedashClient, snippet_ids: Vec<u64>, all: bool) -> Result<()> {
305    let all_snippets = get_all_snippet_metadata()?;
306
307    let snippets_to_deploy = if !snippet_ids.is_empty() {
308        let ids_set: HashSet<_> = snippet_ids.iter().copied().collect();
309        let filtered: Vec<_> = all_snippets
310            .into_iter()
311            .filter(|(id, _)| ids_set.contains(id))
312            .collect();
313
314        if filtered.is_empty() {
315            bail!("None of the specified snippet IDs were found in snippets/ directory");
316        }
317
318        println!("Deploying {} specific snippets...", filtered.len());
319        for (id, trigger) in &filtered {
320            println!("  → {id} - {trigger}");
321        }
322        println!();
323
324        filtered
325    } else if all {
326        println!("Deploying all {} snippets...\n", all_snippets.len());
327        all_snippets
328    } else {
329        let Some(changed_ids) = get_changed_snippet_ids() else {
330            println!("No git repository detected.");
331            println!("Tip: Use --all to deploy all snippets, or specify snippet IDs.");
332            return Ok(());
333        };
334
335        if changed_ids.is_empty() {
336            println!("No changed snippets detected.");
337            println!("Tip: Use --all to deploy all snippets regardless of git status.");
338            return Ok(());
339        }
340
341        let filtered: Vec<_> = all_snippets
342            .into_iter()
343            .filter(|(id, _)| changed_ids.contains(id))
344            .collect();
345
346        println!("Deploying {} changed snippets...", filtered.len());
347        for (id, trigger) in &filtered {
348            println!("  → {id} - {trigger}");
349        }
350        println!();
351
352        filtered
353    };
354
355    for (id, trigger) in &snippets_to_deploy {
356        deploy_one(client, *id, trigger).await?;
357    }
358
359    println!("\n✓ All snippets deployed successfully");
360
361    Ok(())
362}
363
364pub async fn delete(client: &RedashClient, snippet_ids: Vec<u64>) -> Result<()> {
365    let mut errors = Vec::new();
366    let mut deleted_count = 0;
367
368    println!("Deleting {} query snippets...\n", snippet_ids.len());
369
370    for snippet_id in &snippet_ids {
371        match client.delete_query_snippet(*snippet_id).await {
372            Ok(()) => {
373                println!("  ✓ Deleted snippet {snippet_id}");
374
375                if let Ok(Some((sql_path, yaml_path))) = find_snippet_files(*snippet_id) {
376                    if let Err(e) = delete_snippet_files(&sql_path, &yaml_path) {
377                        eprintln!("  ⚠ Failed to delete local files for snippet {snippet_id}: {e}");
378                    } else {
379                        println!("    Deleted local files");
380                    }
381                } else {
382                    println!("    No local files found");
383                }
384
385                deleted_count += 1;
386            }
387            Err(e) => {
388                eprintln!("  ✗ Failed to delete snippet {snippet_id}: {e}");
389                errors.push((*snippet_id, e));
390            }
391        }
392    }
393
394    println!(
395        "\n✓ Deleted {deleted_count}/{} query snippets",
396        snippet_ids.len()
397    );
398
399    if !errors.is_empty() {
400        anyhow::bail!("Failed to delete {} query snippets", errors.len());
401    }
402
403    Ok(())
404}
405
406#[cfg(test)]
407#[allow(clippy::missing_errors_doc)]
408mod tests {
409    use super::*;
410    use tempfile::TempDir;
411
412    #[test]
413    fn test_extract_snippet_ids_from_path_empty() {
414        let temp_dir = TempDir::new().unwrap();
415        let result = extract_snippet_ids_from_path(temp_dir.path());
416        assert!(result.is_ok());
417        assert!(result.unwrap().is_empty());
418    }
419
420    #[test]
421    fn test_extract_snippet_ids_from_path_missing_directory() {
422        let temp_dir = TempDir::new().unwrap();
423        let missing = temp_dir.path().join("does-not-exist");
424        let result = extract_snippet_ids_from_path(&missing);
425        assert!(result.is_ok());
426        assert!(result.unwrap().is_empty());
427    }
428
429    #[test]
430    fn test_extract_snippet_ids_from_path_deduplication() {
431        let temp_dir = TempDir::new().unwrap();
432        let dir = temp_dir.path();
433
434        fs::write(dir.join("31-old_trigger_name.yaml"), "test").unwrap();
435        fs::write(dir.join("31-new_trigger_name.yaml"), "test").unwrap();
436
437        let ids = extract_snippet_ids_from_path(dir).unwrap();
438        assert_eq!(ids, vec![31]);
439    }
440
441    #[test]
442    fn test_extract_snippet_ids_from_path_ignores_non_yaml() {
443        let temp_dir = TempDir::new().unwrap();
444        let dir = temp_dir.path();
445
446        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
447        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "test").unwrap();
448        fs::write(dir.join("README.md"), "test").unwrap();
449
450        let ids = extract_snippet_ids_from_path(dir).unwrap();
451        assert_eq!(ids, vec![31]);
452    }
453
454    #[test]
455    fn test_extract_snippet_ids_from_path_ignores_id_without_separator() {
456        let temp_dir = TempDir::new().unwrap();
457        let dir = temp_dir.path();
458
459        // "31.yaml" has no '-' separator, so the whole stem fails to parse as a u64
460        // and must be silently skipped, not mistaken for id 31.
461        fs::write(dir.join("31.yaml"), "test").unwrap();
462        fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
463
464        let ids = extract_snippet_ids_from_path(dir).unwrap();
465        assert_eq!(ids, vec![42]);
466    }
467
468    #[test]
469    fn test_extract_snippet_ids_from_path_includes_id_zero() {
470        let temp_dir = TempDir::new().unwrap();
471        let dir = temp_dir.path();
472
473        // id 0 is the sentinel for "not yet created" mid-deploy; it must still be
474        // discovered like any other id, not treated as absent/falsy.
475        fs::write(dir.join("0-stmo_cli_selftest.yaml"), "test").unwrap();
476
477        let ids = extract_snippet_ids_from_path(dir).unwrap();
478        assert_eq!(ids, vec![0]);
479    }
480
481    #[test]
482    fn test_extract_snippet_ids_from_path_sorted() {
483        let temp_dir = TempDir::new().unwrap();
484        let dir = temp_dir.path();
485
486        fs::write(dir.join("42-zebra.yaml"), "test").unwrap();
487        fs::write(dir.join("9-hll_convert.yaml"), "test").unwrap();
488        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "test").unwrap();
489
490        let ids = extract_snippet_ids_from_path(dir).unwrap();
491        assert_eq!(ids, vec![9, 31, 42]);
492    }
493
494    #[test]
495    fn test_find_snippet_files_in_found() {
496        let temp_dir = TempDir::new().unwrap();
497        let dir = temp_dir.path();
498
499        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
500        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
501
502        let result = find_snippet_files_in(dir, 31).unwrap();
503        assert!(result.is_some());
504        let (sql_path, yaml_path) = result.unwrap();
505        assert_eq!(
506            Path::new(&sql_path).extension(),
507            Some(std::ffi::OsStr::new("sql"))
508        );
509        assert_eq!(
510            Path::new(&yaml_path).extension(),
511            Some(std::ffi::OsStr::new("yaml"))
512        );
513    }
514
515    #[test]
516    fn test_find_snippet_files_in_no_matching_id() {
517        let temp_dir = TempDir::new().unwrap();
518        let dir = temp_dir.path();
519
520        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
521        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
522
523        let result = find_snippet_files_in(dir, 99).unwrap();
524        assert!(result.is_none());
525    }
526
527    #[test]
528    fn test_find_snippet_files_in_missing_yaml() {
529        let temp_dir = TempDir::new().unwrap();
530        let dir = temp_dir.path();
531
532        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
533
534        let result = find_snippet_files_in(dir, 31).unwrap();
535        assert!(result.is_none());
536    }
537
538    #[test]
539    fn test_find_snippet_files_in_missing_directory() {
540        let temp_dir = TempDir::new().unwrap();
541        let missing = temp_dir.path().join("does-not-exist");
542
543        let result = find_snippet_files_in(&missing, 31).unwrap();
544        assert!(result.is_none());
545    }
546
547    #[test]
548    fn test_find_snippet_files_in_id_zero() {
549        let temp_dir = TempDir::new().unwrap();
550        let dir = temp_dir.path();
551
552        // id 0 is the sentinel used for "not yet created" mid-deploy, before the
553        // server assigns a real id and the files get renamed.
554        fs::write(dir.join("0-stmo_cli_selftest.sql"), "SELECT 1").unwrap();
555        fs::write(dir.join("0-stmo_cli_selftest.yaml"), "id: 0").unwrap();
556
557        let result = find_snippet_files_in(dir, 0).unwrap();
558        assert!(result.is_some());
559    }
560
561    #[test]
562    fn test_find_snippet_files_in_missing_sql() {
563        let temp_dir = TempDir::new().unwrap();
564        let dir = temp_dir.path();
565
566        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
567
568        let result = find_snippet_files_in(dir, 31).unwrap();
569        assert!(result.is_none());
570    }
571
572    #[test]
573    fn test_find_snippet_files_in_exact_id_match_not_prefix() {
574        let temp_dir = TempDir::new().unwrap();
575        let dir = temp_dir.path();
576
577        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.sql"), "SELECT 1").unwrap();
578        fs::write(dir.join("31-reviewbot_e2e_action_ctcs.yaml"), "id: 31").unwrap();
579
580        // Searching for id 3 must not match a file whose id (31) merely starts with "3".
581        let result = find_snippet_files_in(dir, 3).unwrap();
582        assert!(result.is_none());
583    }
584
585    #[test]
586    fn test_get_all_snippet_metadata_from_path_basic() {
587        let temp_dir = TempDir::new().unwrap();
588        let dir = temp_dir.path();
589
590        fs::write(
591            dir.join("31-reviewbot_e2e_action_ctcs.yaml"),
592            "id: 31\ntrigger: reviewbot_e2e_action_ctcs\ndescription: null\n",
593        )
594        .unwrap();
595
596        let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
597        assert_eq!(
598            metadata,
599            vec![(31, "reviewbot_e2e_action_ctcs".to_string())]
600        );
601    }
602
603    #[test]
604    fn test_get_all_snippet_metadata_from_path_sorted_by_id() {
605        let temp_dir = TempDir::new().unwrap();
606        let dir = temp_dir.path();
607
608        fs::write(
609            dir.join("42-zebra.yaml"),
610            "id: 42\ntrigger: zebra\ndescription: null\n",
611        )
612        .unwrap();
613        fs::write(
614            dir.join("9-hll_convert.yaml"),
615            "id: 9\ntrigger: hll_convert\ndescription: null\n",
616        )
617        .unwrap();
618
619        let metadata = get_all_snippet_metadata_from_path(dir).unwrap();
620        assert_eq!(
621            metadata,
622            vec![(9, "hll_convert".to_string()), (42, "zebra".to_string())]
623        );
624    }
625
626    #[test]
627    fn test_get_all_snippet_metadata_from_path_missing_directory_errors() {
628        let temp_dir = TempDir::new().unwrap();
629        let missing = temp_dir.path().join("does-not-exist");
630
631        let result = get_all_snippet_metadata_from_path(&missing);
632        assert!(result.is_err());
633        assert!(
634            result
635                .unwrap_err()
636                .to_string()
637                .contains("snippets directory not found")
638        );
639    }
640
641    #[test]
642    fn test_get_all_snippet_metadata_from_path_empty_directory() {
643        let temp_dir = TempDir::new().unwrap();
644
645        let metadata = get_all_snippet_metadata_from_path(temp_dir.path()).unwrap();
646        assert!(metadata.is_empty());
647    }
648
649    #[test]
650    fn test_get_all_snippet_metadata_from_path_malformed_yaml_errors() {
651        let temp_dir = TempDir::new().unwrap();
652        let dir = temp_dir.path();
653
654        fs::write(
655            dir.join("31-broken.yaml"),
656            "description: missing required fields\n",
657        )
658        .unwrap();
659
660        let result = get_all_snippet_metadata_from_path(dir);
661        assert!(result.is_err());
662        assert!(result.unwrap_err().to_string().contains("Failed to parse"));
663    }
664
665    #[test]
666    fn test_parse_changed_snippet_ids_basic() {
667        let porcelain = " M snippets/31-reviewbot_e2e_action_ctcs.sql\n M snippets/31-reviewbot_e2e_action_ctcs.yaml\n M queries/999-unrelated.sql\n";
668        let ids = parse_changed_snippet_ids(porcelain);
669        assert_eq!(ids, HashSet::from([31]));
670    }
671
672    #[test]
673    fn test_parse_changed_snippet_ids_untracked() {
674        let porcelain =
675            "?? snippets/42-stmo_cli_selftest.sql\n?? snippets/42-stmo_cli_selftest.yaml\n";
676        let ids = parse_changed_snippet_ids(porcelain);
677        assert_eq!(ids, HashSet::from([42]));
678    }
679
680    #[test]
681    fn test_parse_changed_snippet_ids_ignores_non_snippet_extensions() {
682        let porcelain = " M snippets/31-notes.md\n";
683        let ids = parse_changed_snippet_ids(porcelain);
684        assert!(ids.is_empty());
685    }
686
687    #[test]
688    fn test_parse_changed_snippet_ids_empty_input() {
689        assert!(parse_changed_snippet_ids("").is_empty());
690    }
691
692    #[test]
693    fn test_parse_changed_snippet_ids_short_lines_do_not_panic() {
694        // Lines shorter than the 2-char status + 1-space prefix must be skipped,
695        // not sliced into (which would panic on a short/blank line).
696        let porcelain = "\nM\n M\n M snippets/31-reviewbot_e2e_action_ctcs.sql\n";
697        let ids = parse_changed_snippet_ids(porcelain);
698        assert_eq!(ids, HashSet::from([31]));
699    }
700
701    #[test]
702    fn test_parse_changed_snippet_ids_exact_boundary_length_no_panic() {
703        // A line of exactly 3 chars (e.g. " M ") does NOT hit the `len < 3` guard,
704        // so &line[3..] slices to an empty string -- must not panic, and the empty
705        // path must not match "snippets/".
706        let porcelain = " M \n";
707        let ids = parse_changed_snippet_ids(porcelain);
708        assert!(ids.is_empty());
709    }
710
711    #[test]
712    fn test_parse_changed_snippet_ids_handles_rename() {
713        // After `snippets deploy` renames 0-*.* -> {id}-*.*, `git status --porcelain`
714        // reports a rename as "old -> new"; the *new* id must be the one detected.
715        let porcelain = "R  snippets/0-stmo_cli_selftest.sql -> snippets/42-stmo_cli_selftest.sql\nR  snippets/0-stmo_cli_selftest.yaml -> snippets/42-stmo_cli_selftest.yaml\n";
716        let ids = parse_changed_snippet_ids(porcelain);
717        assert_eq!(ids, HashSet::from([42]));
718    }
719}