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 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 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 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 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 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 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 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 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}