1#![allow(clippy::missing_errors_doc)]
2
3use crate::api::RedashClient;
4use crate::models::{Query, QueryMetadata, Visualization, VisualizationMetadata};
5use anyhow::{Context, Result, bail};
6use std::collections::{HashMap, HashSet};
7use std::fs;
8use std::path::Path;
9use std::sync::Arc;
10use tokio::sync::Semaphore;
11use tokio::task::JoinSet;
12
13fn slugify(s: &str) -> String {
14 s.to_lowercase()
15 .chars()
16 .map(|c| if c.is_alphanumeric() { c } else { '-' })
17 .collect::<String>()
18 .split('-')
19 .filter(|s| !s.is_empty())
20 .collect::<Vec<_>>()
21 .join("-")
22}
23
24fn validate_enum_options(metadata: &crate::models::QueryMetadata, yaml_path: &str) -> Result<()> {
25 for param in &metadata.options.parameters {
26 if let Some(enum_opts) = ¶m.enum_options
27 && enum_opts.contains("\\n")
28 {
29 bail!(
30 "In {yaml_path}: parameter '{}' has enumOptions with escaped newlines. \
31 Use YAML multiline format instead:\n\n\
32 enumOptions: |-\n option1\n option2",
33 param.name
34 );
35 }
36 }
37 Ok(())
38}
39
40pub(crate) fn visualizations_differ(
44 local: &[VisualizationMetadata],
45 server: &[Visualization],
46) -> bool {
47 if local.len() != server.len() {
48 return true;
49 }
50
51 for viz in local {
52 let Some(id) = viz.id else {
53 return true;
54 };
55 let Some(server_viz) = server.iter().find(|sv| sv.id == id) else {
56 return true;
57 };
58 if viz.name != server_viz.name
59 || viz.viz_type != server_viz.viz_type
60 || viz.options != server_viz.options
61 || viz.description != server_viz.description
62 {
63 return true;
64 }
65 }
66
67 false
68}
69
70pub(crate) fn tracked_query_differs(
75 local_sql: &str,
76 local_metadata: &QueryMetadata,
77 server: &Query,
78) -> bool {
79 local_sql != server.sql
80 || local_metadata.name != server.name
81 || local_metadata.description != server.description
82 || local_metadata.data_source_id != server.data_source_id
83 || local_metadata.schedule != server.schedule
84 || local_metadata.tags != server.tags
85 || serde_json::to_value(&local_metadata.options).ok()
86 != serde_json::to_value(&server.options).ok()
87 || visualizations_differ(&local_metadata.visualizations, &server.visualizations)
88}
89
90fn read_local_query(id: u64, name: &str) -> Result<(String, QueryMetadata)> {
91 let slug = slugify(name);
92 let sql_path = format!("queries/{id}-{slug}.sql");
93 let yaml_path = format!("queries/{id}-{slug}.yaml");
94
95 let sql = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
96 let metadata_content =
97 fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
98 let metadata: QueryMetadata =
99 serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
100
101 Ok((sql, metadata))
102}
103
104async fn query_changed(client: &RedashClient, id: u64, name: &str) -> Result<bool> {
105 let (sql, metadata) = read_local_query(id, name)?;
106 let server = client
107 .get_query(id)
108 .await
109 .context(format!("Failed to fetch query {id} from Redash"))?;
110 Ok(tracked_query_differs(&sql, &metadata, &server))
111}
112
113const MAX_CONCURRENT_COMPARISONS: usize = 8;
114
115async fn find_changed_queries(
121 client: &RedashClient,
122 all_queries: &[(u64, String)],
123) -> HashSet<u64> {
124 let total = all_queries.len();
125 let semaphore = Arc::new(Semaphore::new(MAX_CONCURRENT_COMPARISONS));
126 let mut join_set = JoinSet::new();
127 let mut changed_ids = HashSet::new();
128
129 for (id, name) in all_queries {
130 let id = *id;
131 if id == 0 {
132 changed_ids.insert(id);
133 continue;
134 }
135
136 let name = name.clone();
137 let client = client.clone();
138 let semaphore = Arc::clone(&semaphore);
139 join_set.spawn(async move {
140 let _permit = semaphore
141 .acquire()
142 .await
143 .expect("semaphore is never closed");
144 (id, query_changed(&client, id, &name).await)
145 });
146 }
147
148 eprintln!("Comparing {total} tracked queries against Redash...");
149 let mut compared = 0;
150
151 while let Some(result) = join_set.join_next().await {
152 compared += 1;
153 match result {
154 Ok((id, Ok(true))) => {
155 changed_ids.insert(id);
156 }
157 Ok((_id, Ok(false))) => {}
158 Ok((id, Err(e))) => {
159 eprintln!(" ⚠ Skipping query {id}: {e}");
160 }
161 Err(join_err) => {
162 eprintln!(" ⚠ A deploy comparison task failed unexpectedly: {join_err}");
163 }
164 }
165 eprintln!("Compared {compared} / {total} queries...");
166 }
167
168 changed_ids
169}
170
171fn get_all_query_metadata_from_path(queries_dir: &Path) -> Result<Vec<(u64, String)>> {
172 if !queries_dir.exists() {
173 bail!("queries directory not found. Run 'stmo-cli fetch' first.");
174 }
175
176 let mut queries = Vec::new();
177 let mut paths_by_id: HashMap<u64, Vec<String>> = HashMap::new();
178
179 for entry in fs::read_dir(queries_dir).context("Failed to read queries directory")? {
180 let entry = entry.context("Failed to read directory entry")?;
181 let path = entry.path();
182
183 if path.extension().is_some_and(|ext| ext == "yaml") {
184 let metadata_content =
185 fs::read_to_string(&path).context(format!("Failed to read {}", path.display()))?;
186
187 let metadata: crate::models::QueryMetadata = serde_yaml::from_str(&metadata_content)
188 .context(format!("Failed to parse {}", path.display()))?;
189
190 paths_by_id
191 .entry(metadata.id)
192 .or_default()
193 .push(path.display().to_string());
194 queries.push((metadata.id, metadata.name));
195 }
196 }
197
198 bail_on_duplicate_ids(&paths_by_id)?;
199
200 queries.sort_by_key(|(id, _)| *id);
201
202 Ok(queries)
203}
204
205fn bail_on_duplicate_ids(paths_by_id: &HashMap<u64, Vec<String>>) -> Result<()> {
206 let mut conflicts: Vec<_> = paths_by_id
207 .iter()
208 .filter(|(_, paths)| paths.len() > 1)
209 .collect();
210
211 if conflicts.is_empty() {
212 return Ok(());
213 }
214
215 conflicts.sort_by_key(|(id, _)| **id);
216 let details: Vec<String> = conflicts
217 .into_iter()
218 .map(|(id, paths)| {
219 let mut paths = paths.clone();
220 paths.sort();
221 format!(" id {id}: {}", paths.join(", "))
222 })
223 .collect();
224
225 bail!(
226 "Multiple local files claim the same id — resolve the conflict before deploying:\n{}",
227 details.join("\n")
228 );
229}
230
231fn get_all_query_metadata() -> Result<Vec<(u64, String)>> {
232 get_all_query_metadata_from_path(Path::new("queries"))
233}
234
235async fn deploy_visualizations(
236 client: &RedashClient,
237 query_id: u64,
238 visualizations: &[crate::models::VisualizationMetadata],
239 server_visualizations: &[crate::models::Visualization],
240) -> Result<()> {
241 let mut matched_server_ids: HashSet<u64> = HashSet::new();
242 for viz in visualizations {
243 if let Some(id) = viz.id {
244 matched_server_ids.insert(id);
245 let viz_to_update = crate::models::Visualization {
246 id,
247 name: viz.name.clone(),
248 viz_type: viz.viz_type.clone(),
249 options: viz.options.clone(),
250 description: viz.description.clone(),
251 };
252 client.update_visualization(&viz_to_update).await?;
253 println!(" ✓ Updated visualization: {} (ID: {id})", viz.name);
254 } else {
255 let server_match = server_visualizations
256 .iter()
257 .find(|sv| sv.viz_type == viz.viz_type && !matched_server_ids.contains(&sv.id));
258 if let Some(server_viz) = server_match {
259 matched_server_ids.insert(server_viz.id);
260 let viz_to_update = crate::models::Visualization {
261 id: server_viz.id,
262 name: viz.name.clone(),
263 viz_type: viz.viz_type.clone(),
264 options: viz.options.clone(),
265 description: viz.description.clone(),
266 };
267 client.update_visualization(&viz_to_update).await?;
268 println!(
269 " ✓ Updated visualization: {} (ID: {})",
270 viz_to_update.name, server_viz.id
271 );
272 } else {
273 let viz_to_create = crate::models::CreateVisualization {
274 query_id,
275 name: viz.name.clone(),
276 viz_type: viz.viz_type.clone(),
277 options: viz.options.clone(),
278 description: viz.description.clone(),
279 };
280 let created = client
281 .create_visualization(query_id, &viz_to_create)
282 .await?;
283 println!(
284 " ✓ Created visualization: {} (ID: {})",
285 created.name, created.id
286 );
287 }
288 }
289 }
290 Ok(())
291}
292
293#[allow(clippy::too_many_lines)]
294pub async fn deploy_one(client: &RedashClient, id: u64, name: &str) -> Result<Query> {
295 let slug = slugify(name);
296 let sql_path = format!("queries/{id}-{slug}.sql");
297 let yaml_path = format!("queries/{id}-{slug}.yaml");
298
299 if !Path::new(&sql_path).exists() {
300 bail!("Query SQL file not found: {sql_path}");
301 }
302 if !Path::new(&yaml_path).exists() {
303 bail!("Query metadata file not found: {yaml_path}");
304 }
305
306 let sql = fs::read_to_string(&sql_path).context(format!("Failed to read {sql_path}"))?;
307
308 let metadata_content =
309 fs::read_to_string(&yaml_path).context(format!("Failed to read {yaml_path}"))?;
310
311 let metadata: crate::models::QueryMetadata =
312 serde_yaml::from_str(&metadata_content).context(format!("Failed to parse {yaml_path}"))?;
313
314 validate_enum_options(&metadata, &yaml_path)?;
315
316 let result_query = if id == 0 {
317 let create_query = crate::models::CreateQuery {
318 name: metadata.name.clone(),
319 description: metadata.description.clone(),
320 sql,
321 data_source_id: metadata.data_source_id,
322 schedule: metadata.schedule.clone(),
323 options: Some(metadata.options.clone()),
324 tags: metadata.tags.clone(),
325 is_archived: false,
326 is_draft: false,
327 };
328 let created = client.create_query(&create_query).await?;
329 let fetched = client.get_query(created.id).await?;
330 let new_slug = slugify(&fetched.name);
331 let new_base = format!("queries/{}-{new_slug}", fetched.id);
332 fs::write(format!("{new_base}.sql"), &fetched.sql)
333 .context(format!("Failed to write {new_base}.sql"))?;
334 let mut new_visualizations: Vec<crate::models::VisualizationMetadata> = fetched
335 .visualizations
336 .iter()
337 .map(crate::models::VisualizationMetadata::from)
338 .collect();
339 new_visualizations.sort_by_key(|v| v.id);
340 let new_metadata = crate::models::QueryMetadata {
341 id: fetched.id,
342 name: fetched.name.clone(),
343 description: fetched.description.clone(),
344 data_source_id: fetched.data_source_id,
345 user_id: fetched.user.as_ref().map(|u| u.id),
346 schedule: fetched.schedule.clone(),
347 options: fetched.options.clone(),
348 visualizations: new_visualizations,
349 tags: fetched.tags.clone(),
350 };
351 let yaml_content =
352 serde_yaml::to_string(&new_metadata).context("Failed to serialize query metadata")?;
353 fs::write(format!("{new_base}.yaml"), yaml_content)
354 .context(format!("Failed to write {new_base}.yaml"))?;
355 fs::remove_file(&sql_path).context(format!("Failed to delete {sql_path}"))?;
356 fs::remove_file(&yaml_path).context(format!("Failed to delete {yaml_path}"))?;
357 println!(" ✓ Created new query: {} - {name}", fetched.id);
358 println!(" Renamed: 0-{slug}.* → {}-{new_slug}.*", fetched.id);
359 fetched
360 } else {
361 let query = Query {
362 id: metadata.id,
363 name: metadata.name.clone(),
364 description: metadata.description.clone(),
365 sql,
366 data_source_id: metadata.data_source_id,
367 user: None,
368 schedule: metadata.schedule.clone(),
369 options: metadata.options.clone(),
370 visualizations: vec![],
371 tags: metadata.tags.clone(),
372 is_archived: false,
373 is_draft: false,
374 updated_at: String::new(),
375 created_at: String::new(),
376 };
377 let result = client.create_or_update_query(&query).await?;
378 let fetched = client.get_query(id).await?;
379 let mut updated_visualizations: Vec<crate::models::VisualizationMetadata> = fetched
380 .visualizations
381 .iter()
382 .map(crate::models::VisualizationMetadata::from)
383 .collect();
384 updated_visualizations.sort_by_key(|v| v.id);
385 let updated_metadata = crate::models::QueryMetadata {
386 id: fetched.id,
387 name: fetched.name.clone(),
388 description: fetched.description.clone(),
389 data_source_id: fetched.data_source_id,
390 user_id: fetched.user.as_ref().map(|u| u.id),
391 schedule: fetched.schedule.clone(),
392 options: fetched.options.clone(),
393 visualizations: updated_visualizations,
394 tags: fetched.tags.clone(),
395 };
396 let yaml_content = serde_yaml::to_string(&updated_metadata)
397 .context("Failed to serialize query metadata")?;
398 fs::write(&yaml_path, yaml_content).context(format!("Failed to write {yaml_path}"))?;
399 println!(" ✓ {id} - {name}");
400 result
401 };
402
403 deploy_visualizations(
404 client,
405 result_query.id,
406 &metadata.visualizations,
407 &result_query.visualizations,
408 )
409 .await?;
410
411 Ok(result_query)
412}
413
414pub async fn deploy(client: &RedashClient, query_ids: Vec<u64>, all: bool) -> Result<()> {
415 let all_queries = get_all_query_metadata()?;
416
417 let queries_to_deploy = if !query_ids.is_empty() {
418 let ids_set: HashSet<_> = query_ids.iter().copied().collect();
419 let filtered: Vec<_> = all_queries
420 .into_iter()
421 .filter(|(id, _)| ids_set.contains(id))
422 .collect();
423
424 if filtered.is_empty() {
425 bail!("None of the specified query IDs were found in queries/ directory");
426 }
427
428 println!("Deploying {} specific queries...", filtered.len());
429 for (id, name) in &filtered {
430 println!(" → {id} - {name}");
431 }
432 println!();
433
434 filtered
435 } else if all {
436 println!("Deploying all {} queries...\n", all_queries.len());
437 all_queries
438 } else {
439 let changed_ids = find_changed_queries(client, &all_queries).await;
440
441 if changed_ids.is_empty() {
442 println!("No changed queries detected.");
443 println!("Tip: Use --all to deploy all queries regardless of differences.");
444 return Ok(());
445 }
446
447 let filtered: Vec<_> = all_queries
448 .into_iter()
449 .filter(|(id, _)| changed_ids.contains(id))
450 .collect();
451
452 println!("Deploying {} changed queries...", filtered.len());
453 for (id, name) in &filtered {
454 println!(" → {id} - {name}");
455 }
456 println!();
457
458 filtered
459 };
460
461 for (id, name) in &queries_to_deploy {
462 deploy_one(client, *id, name).await?;
463 }
464
465 println!("\n✓ All resources deployed successfully");
466
467 Ok(())
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473
474 #[test]
475 fn test_validate_enum_options_rejects_escaped_newlines() {
476 let metadata = crate::models::QueryMetadata {
477 id: 1,
478 name: "Test Query".to_string(),
479 description: None,
480 data_source_id: 1,
481 user_id: None,
482 schedule: None,
483 options: crate::models::QueryOptions {
484 parameters: vec![crate::models::Parameter {
485 name: "test_param".to_string(),
486 title: "Test Param".to_string(),
487 param_type: "enum".to_string(),
488 enum_options: Some("option1\\noption2\\noption3".to_string()),
489 query_id: Some(1),
490 value: None,
491 multi_values_options: None,
492 }],
493 },
494 visualizations: vec![],
495 tags: None,
496 };
497
498 let result = validate_enum_options(&metadata, "test.yaml");
499 assert!(result.is_err());
500 let err_msg = result.unwrap_err().to_string();
501 assert!(err_msg.contains("escaped newlines"));
502 assert!(err_msg.contains("test_param"));
503 assert!(err_msg.contains("YAML multiline format"));
504 }
505
506 #[test]
507 fn test_validate_enum_options_accepts_multiline() {
508 let metadata = crate::models::QueryMetadata {
509 id: 1,
510 name: "Test Query".to_string(),
511 description: None,
512 data_source_id: 1,
513 user_id: None,
514 schedule: None,
515 options: crate::models::QueryOptions {
516 parameters: vec![crate::models::Parameter {
517 name: "test_param".to_string(),
518 title: "Test Param".to_string(),
519 param_type: "enum".to_string(),
520 enum_options: Some("option1\noption2\noption3".to_string()),
521 query_id: Some(1),
522 value: None,
523 multi_values_options: None,
524 }],
525 },
526 visualizations: vec![],
527 tags: None,
528 };
529
530 let result = validate_enum_options(&metadata, "test.yaml");
531 assert!(result.is_ok());
532 }
533
534 #[test]
535 fn test_validate_enum_options_accepts_no_enum() {
536 let metadata = crate::models::QueryMetadata {
537 id: 1,
538 name: "Test Query".to_string(),
539 description: None,
540 data_source_id: 1,
541 user_id: None,
542 schedule: None,
543 options: crate::models::QueryOptions {
544 parameters: vec![crate::models::Parameter {
545 name: "test_param".to_string(),
546 title: "Test Param".to_string(),
547 param_type: "text".to_string(),
548 enum_options: None,
549 query_id: Some(1),
550 value: None,
551 multi_values_options: None,
552 }],
553 },
554 visualizations: vec![],
555 tags: None,
556 };
557
558 let result = validate_enum_options(&metadata, "test.yaml");
559 assert!(result.is_ok());
560 }
561
562 fn make_query_metadata(name: &str, data_source_id: u64) -> QueryMetadata {
563 QueryMetadata {
564 id: 1,
565 name: name.to_string(),
566 description: None,
567 data_source_id,
568 user_id: None,
569 schedule: None,
570 options: crate::models::QueryOptions { parameters: vec![] },
571 visualizations: vec![],
572 tags: None,
573 }
574 }
575
576 fn make_server_query(sql: &str, name: &str, data_source_id: u64) -> Query {
577 Query {
578 id: 1,
579 name: name.to_string(),
580 description: None,
581 sql: sql.to_string(),
582 data_source_id,
583 user: None,
584 schedule: None,
585 options: crate::models::QueryOptions { parameters: vec![] },
586 visualizations: vec![],
587 tags: None,
588 is_archived: false,
589 is_draft: false,
590 updated_at: String::new(),
591 created_at: String::new(),
592 }
593 }
594
595 #[test]
596 fn test_tracked_query_differs_false_when_identical() {
597 let metadata = make_query_metadata("Q", 1);
598 let server = make_server_query("SELECT 1", "Q", 1);
599 assert!(!tracked_query_differs("SELECT 1", &metadata, &server));
600 }
601
602 #[test]
603 fn test_tracked_query_differs_true_when_sql_differs() {
604 let metadata = make_query_metadata("Q", 1);
605 let server = make_server_query("SELECT 1", "Q", 1);
606 assert!(tracked_query_differs("SELECT 2", &metadata, &server));
607 }
608
609 #[test]
610 fn test_tracked_query_differs_true_when_name_differs() {
611 let metadata = make_query_metadata("Local Name", 1);
612 let server = make_server_query("SELECT 1", "Server Name", 1);
613 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
614 }
615
616 #[test]
617 fn test_tracked_query_differs_true_when_data_source_id_differs() {
618 let metadata = make_query_metadata("Q", 1);
619 let server = make_server_query("SELECT 1", "Q", 2);
620 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
621 }
622
623 #[test]
624 fn test_tracked_query_differs_true_when_parameters_differ() {
625 let mut metadata = make_query_metadata("Q", 1);
626 metadata.options.parameters.push(crate::models::Parameter {
627 name: "p".to_string(),
628 title: "P".to_string(),
629 param_type: "text".to_string(),
630 value: None,
631 enum_options: None,
632 query_id: None,
633 multi_values_options: None,
634 });
635 let server = make_server_query("SELECT 1", "Q", 1);
636 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
637 }
638
639 #[test]
640 fn test_tracked_query_differs_true_when_description_differs() {
641 let mut metadata = make_query_metadata("Q", 1);
642 metadata.description = Some("local".to_string());
643 let mut server = make_server_query("SELECT 1", "Q", 1);
644 server.description = Some("server".to_string());
645 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
646 }
647
648 #[test]
649 fn test_tracked_query_differs_true_when_schedule_differs() {
650 let mut metadata = make_query_metadata("Q", 1);
651 metadata.schedule = Some(crate::models::Schedule {
652 interval: Some(3600),
653 time: None,
654 day_of_week: None,
655 until: None,
656 });
657 let server = make_server_query("SELECT 1", "Q", 1);
658 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
659 }
660
661 #[test]
662 fn test_tracked_query_differs_true_when_tags_differ() {
663 let mut metadata = make_query_metadata("Q", 1);
664 metadata.tags = Some(vec!["a".to_string()]);
665 let server = make_server_query("SELECT 1", "Q", 1);
666 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
667 }
668
669 fn make_visualization(id: u64, name: &str) -> Visualization {
670 Visualization {
671 id,
672 name: name.to_string(),
673 viz_type: "CHART".to_string(),
674 options: serde_json::json!({}),
675 description: None,
676 }
677 }
678
679 fn make_visualization_metadata(id: Option<u64>, name: &str) -> VisualizationMetadata {
680 VisualizationMetadata {
681 id,
682 name: name.to_string(),
683 viz_type: "CHART".to_string(),
684 options: serde_json::json!({}),
685 description: None,
686 }
687 }
688
689 #[test]
690 fn test_visualizations_differ_false_when_identical() {
691 let local = vec![make_visualization_metadata(Some(1), "Chart")];
692 let server = vec![make_visualization(1, "Chart")];
693 assert!(!visualizations_differ(&local, &server));
694 }
695
696 #[test]
697 fn test_visualizations_differ_true_when_name_differs() {
698 let local = vec![make_visualization_metadata(Some(1), "New name")];
699 let server = vec![make_visualization(1, "Chart")];
700 assert!(visualizations_differ(&local, &server));
701 }
702
703 #[test]
704 fn test_visualizations_differ_true_when_local_has_no_id() {
705 let local = vec![make_visualization_metadata(None, "Chart")];
708 let server = vec![make_visualization(1, "Chart")];
709 assert!(visualizations_differ(&local, &server));
710 }
711
712 #[test]
713 fn test_visualizations_differ_true_when_counts_differ() {
714 let local = vec![make_visualization_metadata(Some(1), "Chart")];
715 let server = vec![];
716 assert!(visualizations_differ(&local, &server));
717 }
718
719 #[test]
720 fn test_visualizations_differ_true_when_referenced_id_missing_server_side() {
721 let local = vec![make_visualization_metadata(Some(99), "Chart")];
722 let server = vec![make_visualization(1, "Chart")];
723 assert!(visualizations_differ(&local, &server));
724 }
725
726 #[test]
727 fn test_tracked_query_differs_true_when_visualization_only_change() {
728 let mut metadata = make_query_metadata("Q", 1);
729 metadata.visualizations = vec![make_visualization_metadata(Some(1), "New name")];
730 let mut server = make_server_query("SELECT 1", "Q", 1);
731 server.visualizations = vec![make_visualization(1, "Chart")];
732 assert!(tracked_query_differs("SELECT 1", &metadata, &server));
733 }
734
735 const MINIMAL_QUERY_METADATA_YAML: &str =
736 "data_source_id: 1\noptions:\n parameters: []\nvisualizations: []\n";
737
738 #[test]
739 fn test_get_all_query_metadata_from_path_basic() {
740 let temp_dir = tempfile::TempDir::new().unwrap();
741 let dir = temp_dir.path();
742
743 fs::write(
744 dir.join("42-my-query.yaml"),
745 format!("id: 42\nname: my-query\n{MINIMAL_QUERY_METADATA_YAML}"),
746 )
747 .unwrap();
748
749 let metadata = get_all_query_metadata_from_path(dir).unwrap();
750 assert_eq!(metadata, vec![(42, "my-query".to_string())]);
751 }
752
753 #[test]
754 fn test_get_all_query_metadata_from_path_missing_directory_errors() {
755 let temp_dir = tempfile::TempDir::new().unwrap();
756 let missing = temp_dir.path().join("does-not-exist");
757
758 let result = get_all_query_metadata_from_path(&missing);
759 assert!(result.is_err());
760 assert!(
761 result
762 .unwrap_err()
763 .to_string()
764 .contains("queries directory not found")
765 );
766 }
767
768 #[test]
769 fn test_get_all_query_metadata_from_path_rejects_duplicate_ids() {
770 let temp_dir = tempfile::TempDir::new().unwrap();
771 let dir = temp_dir.path();
772
773 fs::write(
774 dir.join("120506-claude-code-direct-reports-model-mix.yaml"),
775 format!(
776 "id: 120506\nname: claude-code-direct-reports-model-mix\n{MINIMAL_QUERY_METADATA_YAML}"
777 ),
778 )
779 .unwrap();
780 fs::write(
781 dir.join("120506-claude-code-user-model-mix.yaml"),
782 format!("id: 120506\nname: claude-code-user-model-mix\n{MINIMAL_QUERY_METADATA_YAML}"),
783 )
784 .unwrap();
785
786 let result = get_all_query_metadata_from_path(dir);
787 assert!(result.is_err());
788 let err_msg = result.unwrap_err().to_string();
789 assert!(err_msg.contains("Multiple local files claim the same id"));
790 assert!(err_msg.contains("id 120506"));
791 assert!(err_msg.contains("claude-code-direct-reports-model-mix.yaml"));
792 assert!(err_msg.contains("claude-code-user-model-mix.yaml"));
793 }
794}