Skip to main content

stmo_cli/commands/
deploy.rs

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) = &param.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
40// A local visualization with no `id` is an unsaved one the user is asking to
41// create — it always counts as "different" regardless of what's on the
42// server.
43pub(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
70// Shared by `deploy` (deciding what to push) and `execute` (deciding whether
71// to sync before running) — compares everything `deploy_one` actually pushes
72// to Redash against what's already there, so "changed" means "differs from
73// the server", not "differs from git's working tree".
74pub(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
115// Compares every tracked query (except `id == 0`, which has nothing on the
116// server yet and is always deployed) against its server copy, up to
117// `MAX_CONCURRENT_COMPARISONS` GETs in flight at once. A query that fails to
118// compare (deleted or archived server-side, unreadable local files, ...) is
119// skipped with a warning rather than aborting the whole run.
120async 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        // No id means "create new" — always counts as a difference, even
706        // though a same-named viz happens to already exist server-side.
707        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}